liu
2 天以前 4ca4444ad33a2a1a51f5c06e78ee9365dfb1eed6
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilder.java
@@ -2,22 +2,42 @@
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.nio.charset.StandardCharsets;
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 二维码标签构建器:统一"二维码编码内容 + 图片叠加可读文本"的文本组装规则,
 * 保证批次、托盘、袋码三种标签的关键信息保留一致。
 * MES 二维码标签构建器:统一"二维码编码内容 + 图片叠加可读文本"的文本组装规则。
 * <p>
 * 配置了 {@code mes.trace-base-url} 时,二维码内容为 H5 溯源地址(带 code 参数),否则回退为纯码值(兼容旧码)
 * 种子标签受《农作物种子标签二维码编码规则》与《农作物种子标签通则》(GB 20464-2024)约束,
 * 最小销售单元的标签必须包含四项信息且按固定顺序排列:
 * <ol>
 *     <li>品种名称</li>
 *     <li>生产经营者名称(进口种子为进口商名称)</li>
 *     <li>单元识别代码(不超过 20 个字符,数字或字母数字组合)</li>
 *     <li>追溯网址</li>
 * </ol>
 * 四要素缺失即构成不合规标签,因此这里采取「缺失即抛异常、拒绝导出」的策略,绝不静默跳过——
 * 静默跳过正是标签缺项流入印刷环节的根因。
 * <p>
 * 二维码内容在配置了 {@code mes.trace-base-url} 时为带 code 参数的溯源 URL,否则按
 * {@code mes.allow-plain-qr-code} 决定是否降级为纯码值(仅限本地兼容历史码)。
 */
public final class MesProQrLabelBuilder {
@@ -25,20 +45,78 @@
    }
    /**
     * H5 溯源页面基础地址,由 {@code MesQrCodeConfig} 启动时注入;为空时二维码编码纯码值
     * 单元识别代码格式: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;
    }
    /**
     * 组装二维码内容:配置了溯源地址则拼 URL,否则返回纯码值
     * 当前应标注的生产经营者名称:进口种子场景优先取进口商名称,否则取生产经营者名称
     *
     * @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,否则按开关决定是否降级为纯码值
     * <p>
     * 未配置溯源地址且未开启降级开关时直接报错,避免印出不含追溯网址的标签。
     */
    private static String buildQrCodeContent(String code) {
        if (StrUtil.isEmpty(traceBaseUrl)) {
            return code;
            if (allowPlainQrCode) {
                return code;
            }
            throw exception(PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED);
        }
        return traceBaseUrl + "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8);
    }
@@ -99,48 +177,115 @@
            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();
    }
    /**
     * 袋码二维码标签
     * 袋码二维码标签(最小销售单元)
     * <p>
     * 按国标要求,四要素置于标签最前并按固定顺序排列:品种名称 → 生产经营者 → 单元识别代码 → 追溯网址;
     * 袋序号、批次号、产线、责任人等生产信息作为辅助内容排列在其后。四要素任一缺失即抛异常,拒绝导出。
     */
    public static QrLabelInfo buildBagCodeLabel(MesProBagCodeDO bagCode,
                                                MesProBatchDO batch,
                                                Map<Long, MesMdmItemDO> itemMap,
                                                Map<Long, AdminUserRespDTO> userMap) {
        LinkedHashMap<String, String> keyValues = new LinkedHashMap<>();
        keyValues.put("袋码", bagCode.getCode());
        keyValues.put("袋序号", bagCode.getBagNo() == null ? "" : String.valueOf(bagCode.getBagNo()));
        keyValues.put("批次号", bagCode.getBatchCode());
        // ===== 四要素(国标要求,顺序固定,缺失即阻断导出)=====
        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 (StrUtil.isNotEmpty(bagCode.getItemName())) {
            keyValues.put("品种", bagCode.getItemName());
        }
        if (batch != null) {
            fillBatchKeyValues(keyValues, batch, itemMap, userMap);
        }
        return QrLabelInfo.builder().fileName(bagCode.getCode()).code(buildQrCodeContent(bagCode.getCode())).keyValues(keyValues).build();
        return QrLabelInfo.builder()
                .fileName(bagCode.getCode())
                .code(buildQrCodeContent(bagCode.getCode()))
                .keyValues(keyValues)
                .build();
    }
    /**
     * 填充批次公共可读文本(品种、规格、产线、生产日期、责任人)
     * 写入必填字段:值为空时抛出对应异常,绝不静默跳过
     */
    private static void putRequired(LinkedHashMap<String, String> 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<Long, MesMdmItemDO> 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;
    }
    /**
     * 填充批次公共可读文本(规格、产线、生产日期、责任人)
     * <p>
     * 注意:此处不再写入「品种」,品种名称统一由 {@link #resolveItemName} 解析,
     * 避免批次品种覆盖袋码品种导致标签与溯源页展示不一致。
     */
    private static void fillBatchKeyValues(LinkedHashMap<String, String> keyValues, MesProBatchDO batch,
                                           Map<Long, MesMdmItemDO> itemMap, Map<Long, AdminUserRespDTO> userMap) {
        MesMdmItemDO item = itemMap.get(batch.getItemId());
        if (item != null) {
            keyValues.put("品种", item.getName());
        if (item != null && StrUtil.isNotEmpty(item.getSpecification())) {
            keyValues.put("规格", item.getSpecification());
        }
        keyValues.put("产线", StrUtil.join(" / ", batch.getLineName(), batch.getLineCode()));
        keyValues.put("生产日期", batch.getProduceDate() == null ? ""
                : LocalDateTimeUtil.format(batch.getProduceDate(), "yyyy-MM-dd HH:mm"));
        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());