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)约束, * 最小销售单元的标签必须包含四项信息且按固定顺序排列: *

    *
  1. 品种名称
  2. *
  3. 生产经营者名称(进口种子为进口商名称)
  4. *
  5. 单元识别代码(不超过 20 个字符,数字或字母数字组合)
  6. *
  7. 追溯网址
  8. *
* 四要素缺失即构成不合规标签,因此这里采取「缺失即抛异常、拒绝导出」的策略,绝不静默跳过—— * 静默跳过正是标签缺项流入印刷环节的根因。 *

* 二维码内容在配置了 {@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()); } } }