package cn.iocoder.yudao.module.mes.framework.qrcode;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_LABEL_ITEM_NAME_MISSING;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_LABEL_OPERATOR_MISSING;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_LABEL_UNIT_CODE_INVALID;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_LABEL_UNIT_CODE_MISSING;
/**
* MES 二维码标签构建器:统一"二维码编码内容 + 图片叠加可读文本"的文本组装规则。
*
* 种子标签受《农作物种子标签二维码编码规则》与《农作物种子标签通则》(GB 20464-2024)约束,
* 最小销售单元的标签必须包含四项信息且按固定顺序排列:
*
* - 品种名称
* - 生产经营者名称(进口种子为进口商名称)
* - 单元识别代码(不超过 20 个字符,数字或字母数字组合)
* - 追溯网址
*
* 四要素缺失即构成不合规标签,因此这里采取「缺失即抛异常、拒绝导出」的策略,绝不静默跳过——
* 静默跳过正是标签缺项流入印刷环节的根因。
*
* 二维码内容在配置了 {@code mes.trace-base-url} 时为带 code 参数的溯源 URL,否则按
* {@code mes.allow-plain-qr-code} 决定是否降级为纯码值(仅限本地兼容历史码)。
*/
public final class MesProQrLabelBuilder {
private MesProQrLabelBuilder() {
}
/**
* 单元识别代码格式:1~20 位数字或字母数字组合(国标要求,不含连字符等符号)
*/
private static final Pattern UNIT_CODE_PATTERN = Pattern.compile("^[0-9A-Za-z]{1,20}$");
/** H5 溯源页面基础地址,由 {@code MesQrCodeConfig} 启动时注入;为空时二维码编码纯码值 */
private static volatile String traceBaseUrl = "";
/** 生产经营者名称,由 {@code MesQrCodeConfig} 启动时注入 */
private static volatile String operatorName = "";
/** 进口商名称,进口种子场景使用 */
private static volatile String importerName = "";
/** 是否进口种子场景 */
private static volatile boolean imported = false;
/** 是否允许二维码降级编码纯码值(仅限本地兼容历史码) */
private static volatile boolean allowPlainQrCode = false;
public static void setTraceBaseUrl(String traceBaseUrl) {
MesProQrLabelBuilder.traceBaseUrl = StrUtil.blankToDefault(traceBaseUrl, "");
}
public static void setOperatorName(String operatorName) {
MesProQrLabelBuilder.operatorName = StrUtil.blankToDefault(operatorName, "");
}
public static void setImporterName(String importerName) {
MesProQrLabelBuilder.importerName = StrUtil.blankToDefault(importerName, "");
}
public static void setImported(boolean imported) {
MesProQrLabelBuilder.imported = imported;
}
public static void setAllowPlainQrCode(boolean allowPlainQrCode) {
MesProQrLabelBuilder.allowPlainQrCode = allowPlainQrCode;
}
/**
* 当前应标注的生产经营者名称:进口种子场景优先取进口商名称,否则取生产经营者名称
*
* @return 名称,未配置时返回空串
*/
public static String resolveOperatorName() {
if (imported && StrUtil.isNotBlank(importerName)) {
return importerName;
}
return operatorName;
}
/**
* 追溯网址(对外展示用,不含 code 参数)
*/
public static String resolveTraceUrl() {
return traceBaseUrl;
}
/**
* 是否进口种子场景:为 true 时标签与溯源页应展示「进口商名称」
*/
public static boolean isImported() {
return imported;
}
/**
* 组装二维码内容:配置了溯源地址则拼 URL,否则按开关决定是否降级为纯码值
*
* 未配置溯源地址且未开启降级开关时直接报错,避免印出不含追溯网址的标签。
*/
private static String buildQrCodeContent(String code) {
if (StrUtil.isEmpty(traceBaseUrl)) {
if (allowPlainQrCode) {
return code;
}
throw exception(PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED);
}
return traceBaseUrl + "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8);
}
/**
* 批次二维码标签
*/
public static QrLabelInfo buildBatchLabel(MesProBatchDO batch,
Map itemMap,
Map userMap) {
// 未补录业务信息时用临时批次码作为二维码内容,并明确提示状态;品种/产线等信息缺失时不拼接空文本
String code = StrUtil.blankToDefault(batch.getCode(), batch.getTempCode());
LinkedHashMap keyValues = new LinkedHashMap<>();
keyValues.put("批次号", code);
if (StrUtil.isEmpty(batch.getCode())) {
keyValues.put("状态", "待补录");
}
MesMdmItemDO item = itemMap.get(batch.getItemId());
if (item != null) {
keyValues.put("品种", item.getName());
keyValues.put("规格", item.getSpecification());
}
if (StrUtil.isNotEmpty(batch.getLineName()) || StrUtil.isNotEmpty(batch.getLineCode())) {
keyValues.put("产线", StrUtil.join(" / ", batch.getLineName(), batch.getLineCode()));
}
if (batch.getProduceDate() != null) {
keyValues.put("生产日期", LocalDateTimeUtil.format(batch.getProduceDate(), "yyyy-MM-dd HH:mm"));
}
if (batch.getPlanQuantity() != null) {
keyValues.put("计划数量", String.valueOf(batch.getPlanQuantity()));
}
AdminUserRespDTO user = userMap.get(batch.getOwnerUserId());
if (user != null) {
keyValues.put("责任人", user.getNickname());
}
return QrLabelInfo.builder().fileName(code).code(buildQrCodeContent(code)).keyValues(keyValues).build();
}
/**
* 托盘二维码标签
*/
public static QrLabelInfo buildPalletLabel(MesProPalletDO pallet,
MesProBatchDO batch,
Map itemMap,
Map userMap) {
// 未绑定批次(临时托盘码)时用临时码作为二维码内容并明确提示状态
String code = StrUtil.blankToDefault(pallet.getCode(), pallet.getTempCode());
LinkedHashMap keyValues = new LinkedHashMap<>();
keyValues.put("托盘码", code);
if (StrUtil.isEmpty(pallet.getCode())) {
keyValues.put("状态", "未绑定批次");
}
keyValues.put("托盘序号", pallet.getPalletNo() == null ? "" : String.valueOf(pallet.getPalletNo()));
if (StrUtil.isNotEmpty(pallet.getBatchCode())) {
keyValues.put("批次号", pallet.getBatchCode());
}
if (pallet.getBagCount() != null) {
keyValues.put("已装袋数", String.valueOf(pallet.getBagCount()));
}
if (batch != null) {
// 品种单独取批次品种:fillBatchKeyValues 已不再写入品种,避免与袋码品种互相覆盖
MesMdmItemDO batchItem = itemMap.get(batch.getItemId());
if (batchItem != null && StrUtil.isNotEmpty(batchItem.getName())) {
keyValues.put("品种", batchItem.getName());
}
fillBatchKeyValues(keyValues, batch, itemMap, userMap);
}
return QrLabelInfo.builder().fileName(code).code(buildQrCodeContent(code)).keyValues(keyValues).build();
}
/**
* 袋码二维码标签(最小销售单元)
*
* 按国标要求,四要素置于标签最前并按固定顺序排列:品种名称 → 生产经营者 → 单元识别代码 → 追溯网址;
* 袋序号、批次号、产线、责任人等生产信息作为辅助内容排列在其后。四要素任一缺失即抛异常,拒绝导出。
*/
public static QrLabelInfo buildBagCodeLabel(MesProBagCodeDO bagCode,
MesProBatchDO batch,
Map itemMap,
Map userMap) {
LinkedHashMap keyValues = new LinkedHashMap<>();
// ===== 四要素(国标要求,顺序固定,缺失即阻断导出)=====
putRequired(keyValues, "品种名称", resolveItemName(bagCode, batch, itemMap),
PRO_QR_LABEL_ITEM_NAME_MISSING);
putRequired(keyValues, imported ? "进口商名称" : "生产经营者名称", resolveOperatorName(),
PRO_QR_LABEL_OPERATOR_MISSING);
putRequired(keyValues, "单元识别代码", resolveUnitCode(bagCode.getCode()),
PRO_QR_LABEL_UNIT_CODE_MISSING);
putRequired(keyValues, "追溯网址", resolveTraceUrl(), PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED);
// ===== 以下为辅助生产信息 =====
if (bagCode.getBagNo() != null) {
keyValues.put("袋序号", String.valueOf(bagCode.getBagNo()));
}
if (StrUtil.isNotEmpty(bagCode.getBatchCode())) {
keyValues.put("批次号", bagCode.getBatchCode());
}
if (StrUtil.isNotEmpty(bagCode.getPalletCode())) {
keyValues.put("绑定托盘", bagCode.getPalletCode());
}
if (batch != null) {
fillBatchKeyValues(keyValues, batch, itemMap, userMap);
}
return QrLabelInfo.builder()
.fileName(bagCode.getCode())
.code(buildQrCodeContent(bagCode.getCode()))
.keyValues(keyValues)
.build();
}
/**
* 写入必填字段:值为空时抛出对应异常,绝不静默跳过
*/
private static void putRequired(LinkedHashMap keyValues, String label,
String value, ErrorCode errorCode) {
if (StrUtil.isBlank(value)) {
throw exception(errorCode);
}
keyValues.put(label, value.trim());
}
/**
* 解析品种名称:优先取袋码自身绑定的品种(数据包生成时写入,未录批次也可印刷),
* 袋码未绑定品种时回退批次品种。
*/
private static String resolveItemName(MesProBagCodeDO bagCode, MesProBatchDO batch,
Map itemMap) {
if (StrUtil.isNotBlank(bagCode.getItemName())) {
return bagCode.getItemName();
}
if (batch == null) {
return null;
}
MesMdmItemDO item = itemMap.get(batch.getItemId());
return item == null ? null : item.getName();
}
/**
* 校验并返回单元识别代码(国标:不超过 20 个字符,数字或字母数字组合)
*/
private static String resolveUnitCode(String code) {
if (StrUtil.isBlank(code)) {
return null;
}
if (!UNIT_CODE_PATTERN.matcher(code).matches()) {
throw exception(PRO_QR_LABEL_UNIT_CODE_INVALID);
}
return code;
}
/**
* 填充批次公共可读文本(规格、产线、生产日期、责任人)
*
* 注意:此处不再写入「品种」,品种名称统一由 {@link #resolveItemName} 解析,
* 避免批次品种覆盖袋码品种导致标签与溯源页展示不一致。
*/
private static void fillBatchKeyValues(LinkedHashMap keyValues, MesProBatchDO batch,
Map itemMap, Map userMap) {
MesMdmItemDO item = itemMap.get(batch.getItemId());
if (item != null && StrUtil.isNotEmpty(item.getSpecification())) {
keyValues.put("规格", item.getSpecification());
}
if (StrUtil.isNotEmpty(batch.getLineName()) || StrUtil.isNotEmpty(batch.getLineCode())) {
keyValues.put("产线", StrUtil.join(" / ", batch.getLineName(), batch.getLineCode()));
}
if (batch.getProduceDate() != null) {
keyValues.put("生产日期", LocalDateTimeUtil.format(batch.getProduceDate(), "yyyy-MM-dd HH:mm"));
}
AdminUserRespDTO user = userMap.get(batch.getOwnerUserId());
if (user != null) {
keyValues.put("责任人", user.getNickname());
}
}
}