| | |
| | | |
| | | 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 { |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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)) { |
| | | if (allowPlainQrCode) { |
| | | return code; |
| | | } |
| | | throw exception(PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED); |
| | | } |
| | | return traceBaseUrl + "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8); |
| | | } |
| | |
| | | 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())); |
| | | |
| | | // ===== 四要素(国标要求,顺序固定,缺失即阻断导出)===== |
| | | 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()); |
| | | } |
| | | if (StrUtil.isNotEmpty(batch.getLineName()) || StrUtil.isNotEmpty(batch.getLineCode())) { |
| | | keyValues.put("产线", StrUtil.join(" / ", batch.getLineName(), batch.getLineCode())); |
| | | keyValues.put("生产日期", batch.getProduceDate() == null ? "" |
| | | : LocalDateTimeUtil.format(batch.getProduceDate(), "yyyy-MM-dd HH:mm")); |
| | | } |
| | | 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()); |