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 二维码标签构建器:统一"二维码编码内容 + 图片叠加可读文本"的文本组装规则。
|
* <p>
|
* 种子标签受《农作物种子标签二维码编码规则》与《农作物种子标签通则》(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 {
|
|
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,否则按开关决定是否降级为纯码值
|
* <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);
|
}
|
|
/**
|
* 批次二维码标签
|
*/
|
public static QrLabelInfo buildBatchLabel(MesProBatchDO batch,
|
Map<Long, MesMdmItemDO> itemMap,
|
Map<Long, AdminUserRespDTO> userMap) {
|
// 未补录业务信息时用临时批次码作为二维码内容,并明确提示状态;品种/产线等信息缺失时不拼接空文本
|
String code = StrUtil.blankToDefault(batch.getCode(), batch.getTempCode());
|
LinkedHashMap<String, String> 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<Long, MesMdmItemDO> itemMap,
|
Map<Long, AdminUserRespDTO> userMap) {
|
// 未绑定批次(临时托盘码)时用临时码作为二维码内容并明确提示状态
|
String code = StrUtil.blankToDefault(pallet.getCode(), pallet.getTempCode());
|
LinkedHashMap<String, String> 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();
|
}
|
|
/**
|
* 袋码二维码标签(最小销售单元)
|
* <p>
|
* 按国标要求,四要素置于标签最前并按固定顺序排列:品种名称 → 生产经营者 → 单元识别代码 → 追溯网址;
|
* 袋序号、批次号、产线、责任人等生产信息作为辅助内容排列在其后。四要素任一缺失即抛异常,拒绝导出。
|
*/
|
public static QrLabelInfo buildBagCodeLabel(MesProBagCodeDO bagCode,
|
MesProBatchDO batch,
|
Map<Long, MesMdmItemDO> itemMap,
|
Map<Long, AdminUserRespDTO> userMap) {
|
LinkedHashMap<String, String> 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<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 && 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());
|
}
|
}
|
|
}
|