liu
昨天 4ca4444ad33a2a1a51f5c06e78ee9365dfb1eed6
feat(mes): 种子二维码标签国标四要素合规改造

依据《农作物种子标签二维码编码规则》与 GB 20464-2024,最小销售单元标签
必须包含品种名称、生产经营者名称、单元识别代码、追溯网址四项信息并按序排列。

标签生成:
- 修复标签中文渲染为方框:字体选择改为按字形探测(canDisplay),剔除纯拉丁字体 Arial
- 标签按国标顺序输出四要素,任一缺失即拒绝导出,不再静默跳过空字段
- 新增单元识别代码格式校验(不超过 20 个字符,数字或字母数字组合)
- 修复标签品种被批次品种覆盖的问题(原注释写"优先取袋码品种",实现相反)

数据源统一:
- 新增 mes.operator-name / importer-name / imported 配置项
- 生产经营者名称与追溯网址统一由后端下发,消除前端硬编码
- 新增免登录接口 /public/mes/trace/config,H5 溯源页改为接口获取
- 溯源查询响应新增 codeStatus / operatorName / traceUrl 字段

生命周期与防伪:
- 已作废袋码在溯源侧失效,返回 codeStatus=CANCELED 并提示防伪警示
- 已作废袋码被扫仍记录扫码事件(作废码被扫是重要防伪信号)
- 导出二维码标签排除已作废袋码,避免作废标签误印导致真品被误判为假货
- 非本地环境启动校验追溯网址,内网/占位符地址拒绝启动

其他:
- H5 溯源页展示加工批次与物流销售信息(国标要求追溯网址可查)
- 托盘码/批次码不再展示为「单元识别代码」,避免误导消费者
- 袋序号改为 Redis 原子分配,修复并发生成产生重复序号的竞态
- 修正单元识别代码的错误接口示例值(原示例 21 字符且含连字符,本身违反国标)
- 补充前端联调方案文档 docs/seed_qr_label_gb_compliance_integration.md

Co-Authored-By: Claude <noreply@anthropic.com>
已添加3个文件
已修改18个文件
976 ■■■■■ 文件已修改
docs/seed_qr_label_gb_compliance_integration.md 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/pub/trace/MesProTracePublicController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/pub/trace/vo/MesProTracePublicConfigRespVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/pub/trace/vo/MesProTracePublicRespVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/MesCodeRedisDAO.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/trace/MesTraceCodeStatusEnum.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesProperties.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesQrCodeConfig.java 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilder.java 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/trace/MesProTracePublicService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/trace/MesProTracePublicServiceImpl.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-thzy.yaml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application.yaml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/static/trace.html 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/seed_qr_label_gb_compliance_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,159 @@
# ç§å­äºŒç»´ç æ ‡ç­¾å›½æ ‡åˆè§„ - å‰ç«¯è”调方案
## èƒŒæ™¯
依据《农作物种子标签二维码编码规则》与《农作物种子标签通则》(GB 20464-2024),
每个二维码对应唯一的最小销售单元种子且不得重复使用,二维码必须包含四项信息并按顺序排列:
1. å“ç§åç§°
2. ç”Ÿäº§ç»è¥è€…名称(进口种子为进口商名称)
3. å•元识别代码(不超过 20 ä¸ªå­—符,数字或字母数字组合)
4. è¿½æº¯ç½‘址
本次改造将「生产经营者名称」与「追溯网址」收敛为后端统一数据源,并让已作废的追溯码对外失效。
## æ¶‰åŠé¡µé¢
- H5 æ‰«ç æº¯æºé¡µ `trace.html`(已完成改造,无需前端另行处理)
- ç®¡ç†åŽå° - èµ‹ç ç®¡ç†é¡µï¼ˆèœå• `mes/pro/bagCode/index`,前端工程中**当前缺失**,需新建)
- ç®¡ç†åŽå° - æ‰˜ç›˜ç ç®¡ç†é¡µ / æ‰¹æ¬¡ç”Ÿäº§ç®¡ç†é¡µï¼ˆäºŒç»´ç é¢„览与导出,如有)
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž | é‰´æƒ |
|------|------|------|------|
| GET | `/public/mes/trace/config` | **新增**:获得溯源页配置(生产经营者名称、追溯网址) | å…ç™»å½• |
| GET | `/public/mes/trace/query` | è¿½æº¯æŸ¥è¯¢ï¼Œ**响应新增 3 ä¸ªå­—段** | å…ç™»å½• |
| GET | `/admin-api/mes/pro/bag-code/export-qr` | å¯¼å‡ºäºŒç»´ç æ ‡ç­¾ ZIP,**新增校验与拒绝逻辑** | `mes:pro-bag-code:export` |
| GET | `/admin-api/mes/pro/bag-code/export-excel` | å¯¼å‡ºè¢‹ç  Excel | `mes:pro-bag-code:export` |
### 1. æ–°å¢žï¼šèŽ·å¾—æº¯æºé¡µé…ç½®
`GET /public/mes/trace/config`
**请求参数:** æ— 
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "operatorName": "新疆塔里木河种业股份有限公司",
    "traceUrl": "http://1.95.78.124:9008/prod/trace.html",
    "imported": false
  },
  "msg": ""
}
```
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| operatorName | String | ç”Ÿäº§ç»è¥è€…名称;`imported=true` æ—¶ä¸ºè¿›å£å•†åç§° |
| traceUrl | String | è¿½æº¯ç½‘址,与二维码内容前缀完全一致(不含 `code` å‚数) |
| imported | Boolean | æ˜¯å¦è¿›å£ç§å­åœºæ™¯ï¼›ä¸º true æ—¶é¡µé¢å­—段名应显示「进口商名称」 |
### 2. è¿½æº¯æŸ¥è¯¢å“åº”新增字段
`GET /public/mes/trace/query?code=xxx`
**响应字段新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| codeStatus | String | `VALID`-有效 / `CANCELED`-已作废 |
| operatorName | String | ç”Ÿäº§ç»è¥è€…名称(与标签印刷内容同源) |
| traceUrl | String | è¿½æº¯ç½‘址(与标签印刷内容同源) |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "sourceType": "BAG",
    "requestCode": "0286267849370923",
    "codeStatus": "VALID",
    "operatorName": "新疆塔里木河种业股份有限公司",
    "traceUrl": "http://1.95.78.124:9008/prod/trace.html",
    "batchBound": false,
    "itemCode": "xxx",
    "itemName": "塔河4号",
    "itemUnitMeasureName": "48千克"
  }
}
```
## å­—段展示规则
### H5 æº¯æºé¡µï¼ˆè¿½æº¯æŸ¥è¯¢åŒºå—)
四要素必须按国标顺序展示,其余信息排列其后:
| é¡ºåº | å­—段 | å–值来源 | è¯´æ˜Ž |
|------|------|----------|------|
| 1 | å“ç§åç§° | `itemName` + `itemSpecification` | å›½æ ‡è¦ç´ â‘  |
| 2 | ç”Ÿäº§ç»è¥è€… / è¿›å£å•†åç§° | `operatorName`,退回 `/config` çš„ `operatorName` | å›½æ ‡è¦ç´ â‘¡ï¼›`imported=true` æ—¶æ ‡ç­¾æ”¹ä¸ºã€Œè¿›å£å•†åç§°ã€ |
| 3 | å•元识别代码 | `requestCode` åŽ» URL åŽçš„码值 | å›½æ ‡è¦ç´ â‘¢ã€‚**仅袋码(`sourceType=BAG`)使用此名称**;托盘码/批次码展示为「托盘码(非最小销售单元)」「批次码(非最小销售单元)」,避免误导 |
| 4 | è¿½æº¯ç½‘址 | `traceUrl`,退回 `/config` çš„ `traceUrl` | å›½æ ‡è¦ç´ â‘£ï¼Œå¯ç‚¹å‡»è·³è½¬ |
| 5+ | è®¡é‡å•位、追溯类型、批次号、生产日期、产线 | å„自字段 | è¾…助信息 |
### H5 æº¯æºé¡µï¼ˆç”Ÿäº§æ˜Žç»†æŠ˜å åŒºï¼‰
国标要求追溯网址应可查询「种子加工批次」及「物流销售信息」,因此以下区块**对外展示**(默认折叠不展开):
| åŒºå— | æ•°æ®æ¥æº | è¯´æ˜Ž |
|------|----------|------|
| ç”Ÿäº§æ‰¹æ¬¡ä¿¡æ¯ | `batchCode`、`batchStatusName`、`produceDate`、`lineName`、`planQuantity` ç­‰ | å¯¹åº”国标「种子加工批次」;`batchBound=false`(未录入批次)时**不展示该区块**,避免暗示无加工批次 |
| ç§å­æµå‘信息 | `seedFlows`(销售出库 bizType=119)或 `salesTargets` | å¯¹åº”国标「物流销售信息」,含来源单号、客户、数量、仓库/库区 |
| åŠ å·¥æµè½¬ | `processTraces` | åŠ å·¥èŠ‚ç‚¹æµè½¬è®°å½• |
以下区块**仅内部可见,不对外展示**(如需一并展示,将 `detailSections(d, false)` æ”¹ä¸º `detailSections(d, true)`):
| åŒºå— | ä¸å±•示原因 |
|------|-----------|
| ç”Ÿäº§äººå‘˜ | æ¶‰åŠä¸ªäººä¿¡æ¯ |
| è´¨æ£€ä¿¡æ¯ã€æ‰˜ç›˜ä¿¡æ¯ã€è¢‹ç åˆ—表、采购来源 | å†…部明细,非消费者关切 |
### ç®¡ç†åŽå° - èµ‹ç ç®¡ç†é¡µ
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| å“ç§åç§° | åˆ—表、详情 | è¢‹ç ç»‘定的品种 |
| å•元识别代码 | åˆ—表、详情 | å³è¢‹ç  `code` |
| çŠ¶æ€ | åˆ—表 | å·²ç”Ÿæˆ / å·²å¯¼å‡ºå°åˆ· / å·²ä½œåºŸ |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å¯¼å‡ºäºŒç»´ç æ ‡ç­¾ | å››è¦ç´ ä»»ä¸€ç¼ºå¤±åˆ™**拒绝导出并报错**,不再静默跳过空字段 |
| å››è¦ç´ ç¼ºå¤±æç¤º | ç¼ºå°‘品种名称 â†’ 1040750021;缺少生产经营者 â†’ 1040750022;缺少单元识别代码 â†’ 1040750023;未配置追溯网址 â†’ 1040750020 |
| å•元识别代码格式 | å¿…须为 1~20 ä½æ•°å­—或字母数字组合,含连字符等符号时报 1040750024 |
| æ‰«ç å‘½ä¸­å·²ä½œåºŸè¢‹ç  | è¿”回 `codeStatus=CANCELED`,页面显示「该追溯码已作废,非本公司有效产品,谨防假冒」,且不再展示溯源详情 |
| å·²ä½œåºŸç è¢«æ‰«æ | **仍记录扫码事件**(作废码被扫是重要防伪信号),不计为查询失败 |
| æœªé…ç½®è¿½æº¯ç½‘址 | éž local çŽ¯å¢ƒå¯åŠ¨æ—¶ç›´æŽ¥å¤±è´¥ï¼›local çŽ¯å¢ƒå¯é€šè¿‡ `mes.allow-plain-qr-code=true` å…¼å®¹åŽ†å²ç  |
| å¯¼å‡ºäºŒç»´ç æ ‡ç­¾ | **已作废的袋码不再导出**(按批次/生成组导出时均排除)。作废码标签若印出并贴到产品上,消费者扫码会被提示「该追溯码已作废,谨防假冒」,导致真品被误判为假货 |
## é…ç½®é¡¹
后端配置项(`application-*.yaml` çš„ `mes` å‰ç¼€ï¼‰ï¼š
| é…ç½®é¡¹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|
| `mes.trace-base-url` | æ˜¯ | è¿½æº¯ç½‘址,必须为公众可访问地址。非 local çŽ¯å¢ƒè‹¥ä¸ºå†…ç½‘ IP / localhost / å ä½ç¬¦å°†**拒绝启动** |
| `mes.operator-name` | æ˜¯ | ç”Ÿäº§ç»è¥è€…名称。未配置则无法导出标签(启动时告警) |
| `mes.importer-name` | å¦ | è¿›å£å•†åç§°ï¼Œè¿›å£ç§å­åœºæ™¯å¡«å†™ |
| `mes.imported` | å¦ | æ˜¯å¦è¿›å£ç§å­åœºæ™¯ï¼Œé»˜è®¤ false |
| `mes.allow-plain-qr-code` | å¦ | æ˜¯å¦å…è®¸äºŒç»´ç ç¼–码纯码值,仅供本地兼容历史码,默认 false |
## æ³¨æ„äº‹é¡¹
- **前端禁止再硬编码生产经营者名称与追溯网址**,必须通过 `/public/mes/trace/config` èŽ·å–ï¼Œå¦åˆ™ä¼šä¸ŽäºŒç»´ç å†…å®¹ä¸ä¸€è‡´ã€‚
- è¿½æº¯ç½‘址在二维码内容与页面展示之间只有 `mes.trace-base-url` ä¸€ä¸ªçœŸå€¼æºï¼Œå‰ç«¯å±•示时不要自行拼接 `/prod` ç­‰è·¯å¾„。
- å·²ä½œåºŸç çš„判定覆盖三种入口:袋码(状态 30)、托盘码(状态 20)、批次码(状态 40),三者的作废枚举值不同,前端不要共用常量判断。
- **存量标签需重新导出**:改造前导出的二维码内容已固化,其中追溯网址可能为内网地址且标签缺少四要素,需重新导出印刷。
- **袋序号改为原子分配**:原先由「查库内最大值再 +1」得到,并发生成会产生重复袋序号(袋序号会印在标签上);现已改为 Redis åŽŸå­è‡ªå¢žåˆ†é…ï¼Œå¹¶ç”¨åº“å†…æœ€å¤§å€¼æ ¡æ­£åŸºå‡†ã€‚åºå·**只保证不重复、不回退,不保证连续**(并发下不同请求拿到不同区段,属正常)。
- ç®¡ç†åŽå°èµ‹ç ç®¡ç†é¡µé¢ï¼ˆ`mes/pro/bagCode/index`)在前端工程中缺失,业务人员目前无法自助查看/导出二维码,建议补充。
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java
@@ -143,7 +143,8 @@
    @GetMapping("/get-by-code")
    @Operation(summary = "扫码追溯:根据袋码(二维码内容)反查批次、产线、责任人等信息")
    @Parameter(name = "code", description = "袋码(二维码内容)", required = true, example = "PC20260818L01001-0001")
    @Parameter(name = "code", description = "单元识别代码(国标:不超过 20 ä¸ªå­—符,数字或字母数字组合)",
            required = true, example = "0286267849370923")
    @PreAuthorize("@ss.hasPermission('mes:pro-bag-code:query')")
    public CommonResult<MesProBagCodeTraceRespVO> getBagCodeByCode(@RequestParam("code") String code) {
        MesProBagCodeDO bagCode = bagCodeService.getBagCodeByCode(code);
@@ -197,11 +198,12 @@
                                HttpServletResponse response) throws IOException {
        List<MesProBagCodeDO> list;
        String zipName;
        // åªå¯¼å‡ºå¯å°åˆ·çš„袋码:已作废袋码的标签不得再印刷(贴到产品上会被扫码提示"已作废,谨防假冒")
        if (StrUtil.isNotEmpty(uuid)) {
            list = bagCodeService.getBagCodeListByUuid(uuid);
            list = bagCodeService.getPrintableBagCodeListByUuid(uuid);
            zipName = "袋码二维码-" + uuid;
        } else if (batchId != null) {
            list = bagCodeService.getBagCodeListByBatchId(batchId);
            list = bagCodeService.getPrintableBagCodeListByBatchId(batchId);
            zipName = "袋码二维码-" + batchId;
        } else {
            throw exception(PRO_QR_EXPORT_PARAMS_INVALID);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/pub/trace/MesProTracePublicController.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.mes.controller.pub.trace;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.controller.pub.trace.vo.MesProTracePublicConfigRespVO;
import cn.iocoder.yudao.module.mes.controller.pub.trace.vo.MesProTracePublicRespVO;
import cn.iocoder.yudao.module.mes.service.trace.MesProTracePublicService;
import io.swagger.v3.oas.annotations.Operation;
@@ -38,4 +39,11 @@
        return success(service.queryTrace(code));
    }
    @GetMapping("/config")
    @PermitAll
    @Operation(summary = "获得溯源页配置(生产经营者名称、追溯网址)")
    public CommonResult<MesProTracePublicConfigRespVO> getConfig() {
        return success(service.getPublicConfig());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/pub/trace/vo/MesProTracePublicConfigRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.mes.controller.pub.trace.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
 * H5 æ‰«ç æº¯æº - é¡µé¢é…ç½® Response VO
 * <p>
 * å‘ H5 æº¯æºé¡µä¸‹å‘「生产经营者名称」与「追溯网址」,二者均为种子标签四要素的组成部分。
 * ç”±åŽç«¯ç»Ÿä¸€ä¸‹å‘,保证二维码内容、标签印刷内容与 H5 é¡µé¢å±•示三者一致,
 * é¿å…å‰ç«¯ç¡¬ç¼–码导致的多真值源不一致问题。
 */
@Schema(description = "H5 æ‰«ç æº¯æº - é¡µé¢é…ç½® Response VO")
@Data
public class MesProTracePublicConfigRespVO {
    @Schema(description = "生产经营者名称(进口种子场景为进口商名称)", example = "新疆塔里木河种业股份有限公司")
    private String operatorName;
    @Schema(description = "追溯网址(与二维码内容前缀一致,不含 code å‚数)",
            example = "http://1.95.78.124:9008/prod/trace.html")
    private String traceUrl;
    @Schema(description = "是否进口种子场景:为 true æ—¶é¡µé¢æ ‡ç­¾åº”展示「进口商名称」")
    private Boolean imported;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/pub/trace/vo/MesProTracePublicRespVO.java
@@ -24,12 +24,25 @@
    @Schema(description = "追溯入口类型:BAG-袋码 / PALLET-托盘码 / BATCH-批次码", example = "BAG")
    private String sourceType;
    @Schema(description = "请求的追溯码(原始扫码值)", example = "PC20260828twoITEM_001")
    @Schema(description = "请求的追溯码(原始扫码值)", example = "0286267849370923")
    private String requestCode;
    @Schema(description = "追溯码有效性:VALID-有效 / CANCELED-已作废(作废码不得作为有效产品流通)",
            example = "VALID")
    private String codeStatus;
    @Schema(description = "是否已录入生产批次(false è¡¨ç¤ºè¢‹ç /托盘已生成但尚未录入批次,此时仅返回品种信息)", example = "true")
    private Boolean batchBound;
    // ====== æ ‡ç­¾å››è¦ç´ åŒºå—(与二维码标签印刷内容保持一致) ======
    @Schema(description = "生产经营者名称(进口种子场景为进口商名称)",
            example = "新疆塔里木河种业股份有限公司")
    private String operatorName;
    @Schema(description = "追溯网址(与二维码内容前缀一致)",
            example = "http://1.95.78.124:9008/prod/trace.html")
    private String traceUrl;
    // ====== å“ç§ä¿¡æ¯åŒºå— ======
    @Schema(description = "品种编码")
    private String itemCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java
@@ -5,6 +5,7 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodePageReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeStatusEnum;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -72,6 +73,29 @@
        return selectList(MesProBagCodeDO::getBatchId, batchId);
    }
    /**
     * æŸ¥è¯¢ç”Ÿæˆç»„下可印刷的袋码(排除已作废),用于二维码标签导出
     * <p>
     * å·²ä½œåºŸè¢‹ç çš„æ ‡ç­¾ä¸å¾—再印刷:其标签若贴到产品上,消费者扫码会被提示「该追溯码已作废」,
     * å¯¼è‡´çœŸå“è¢«è¯¯åˆ¤ä¸ºå‡è´§ã€‚
     */
    default List<MesProBagCodeDO> selectPrintableListByUuid(String uuid) {
        return selectList(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .eq(MesProBagCodeDO::getUuid, uuid)
                .ne(MesProBagCodeDO::getStatus, MesProBagCodeStatusEnum.CANCELED.getStatus())
                .orderByAsc(MesProBagCodeDO::getBagNo));
    }
    /**
     * æŸ¥è¯¢æ‰¹æ¬¡ä¸‹å¯å°åˆ·çš„袋码(排除已作废),用于二维码标签导出
     */
    default List<MesProBagCodeDO> selectPrintableListByBatchId(Long batchId) {
        return selectList(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .eq(MesProBagCodeDO::getBatchId, batchId)
                .ne(MesProBagCodeDO::getStatus, MesProBagCodeStatusEnum.CANCELED.getStatus())
                .orderByAsc(MesProBagCodeDO::getBagNo));
    }
    default Long selectCountByBatchId(Long batchId) {
        return selectCount(MesProBagCodeDO::getBatchId, batchId);
    }
@@ -137,10 +161,13 @@
    }
    /**
     * æŸ¥è¯¢æ‰¹æ¬¡å†…袋码最大序号(包含已逻辑删除记录,保证序号不回退、码值唯一)
     * æŸ¥è¯¢å…¨å±€æœ€å¤§è¢‹åºå·ï¼ˆè¢‹åºå·ä¸ºå…¨å±€åºå·ï¼Œä¸åŒºåˆ†æ‰¹æ¬¡ï¼›åŒ…含已逻辑删除记录,保证序号不回退)
     * <p>
     * ä»…用于为 Redis åºå·åˆ†é…æä¾›åŸºå‡†å€¼ï¼Œä¸è¦åœ¨ä¸šåŠ¡ä¸­ç›´æŽ¥ç”¨ã€Œè¿”å›žå€¼ + 1」作为起始序号——
     * é‚£æ˜¯å…ˆè¯»åŽå†™ï¼Œå¹¶å‘生成会产生重复序号。请使用
     * {@code MesCodeRedisDAO#allocateBagNoStart(int, Integer)} åŽŸå­åˆ†é…ã€‚
     *
     * @param batchId æ‰¹æ¬¡ç¼–号
     * @return æœ€å¤§è¢‹åºå·ï¼Œæ— è®°å½•返回 0
     * @return å…¨å±€æœ€å¤§è¢‹åºå·ï¼Œæ— è®°å½•返回 0
     */
    @Select("SELECT IFNULL(MAX(bag_no), 0) FROM mes_pro_bag_code")
    Integer selectMaxBagNo();
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/MesCodeRedisDAO.java
@@ -3,7 +3,11 @@
import cn.iocoder.yudao.framework.common.util.number.BizNoUtils;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
 * MES ä¸šåŠ¡ç¼–ç ç”Ÿæˆçš„ Redis DAO(实例码:袋码 / æ‰˜ç›˜ç  / ä¸´æ—¶ç ç­‰ï¼‰ã€‚
@@ -13,6 +17,26 @@
 */
@Repository
public class MesCodeRedisDAO {
    /**
     * è¢‹åºå·è‡ªå¢ž KEY(全局袋序号,保证同一批/跨批生成时不重复)
     */
    private static final String BAG_NO_SEQ_KEY = "mes:bag_no:seq";
    /**
     * åŽŸå­åˆ†é…è¢‹åºå·åŒºé—´çš„ Lua è„šæœ¬ã€‚
     * <p>
     * å…ˆæŒ‰åº“内最大序号校正基准值(Redis é¦–次使用、或库内数据被直接导入导致基准落后时),
     * å†åŽŸå­è‡ªå¢žå¹¶è¿”å›žè‡ªå¢žåŽçš„å€¼ã€‚æ•´ä¸ªã€Œæ ¡æ­£ + è‡ªå¢žã€åœ¨ Redis å•线程内完成,避免并发下互相覆盖计数。
     * <p>
     * KEYS[1] = åºå· KEY;ARGV[1] = æœ¬æ¬¡éœ€è¦çš„个数;ARGV[2] = åº“内当前最大序号
     */
    private static final RedisScript<Long> ALLOCATE_BAG_NO_SCRIPT = new DefaultRedisScript<>(
            "local cur = redis.call('GET', KEYS[1]) "
                    + "local base = tonumber(ARGV[2]) "
                    + "if (not cur) or (tonumber(cur) < base) then redis.call('SET', KEYS[1], base) end "
                    + "return redis.call('INCRBY', KEYS[1], ARGV[1])",
            Long.class);
    @Resource
    private StringRedisTemplate stringRedisTemplate;
@@ -34,4 +58,27 @@
        throw new IllegalStateException("生成唯一业务编码失败,请重试");
    }
    /**
     * åŽŸå­åˆ†é…ä¸€æ®µè¿žç»­çš„è¢‹åºå·ï¼Œè¿”å›žåŒºé—´èµ·å§‹å€¼ã€‚
     * <p>
     * è¢‹åºå·åŽŸå…ˆç”±ã€ŒæŸ¥åº“å†…æœ€å¤§å€¼å† +1」得到,并发生成时两个请求会读到同一个最大值,
     * ä»Žè€Œäº§å‡ºé‡å¤çš„袋序号(袋序号会印在标签上)。此处改为 Redis åŽŸå­è‡ªå¢žåˆ†é…ï¼Œ
     * å¹¶ç”¨åº“内当前最大值做基准校正,保证序号既不重复也不回退。
     *
     * @param count      æœ¬æ¬¡éœ€è¦çš„连续序号个数(须大于 0)
     * @param dbMaxBagNo æ•°æ®åº“当前最大袋序号(含已逻辑删除记录,传 null è§†ä¸º 0)
     * @return åŒºé—´èµ·å§‹åºå·ï¼ˆå«ï¼‰ï¼Œå¯ç”¨åŒºé—´ä¸º [start, start + count - 1]
     */
    public int allocateBagNoStart(int count, Integer dbMaxBagNo) {
        if (count <= 0) {
            throw new IllegalArgumentException("袋序号分配个数必须大于 0");
        }
        Long end = stringRedisTemplate.execute(ALLOCATE_BAG_NO_SCRIPT, List.of(BAG_NO_SEQ_KEY),
                String.valueOf(count), String.valueOf(dbMaxBagNo == null ? 0 : dbMaxBagNo));
        if (end == null) {
            throw new IllegalStateException("分配袋序号失败,请重试");
        }
        return (int) (end - count + 1);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -860,6 +860,13 @@
    ErrorCode PRO_BAG_CODE_PALLET_COUNT_REQUIRED = new ErrorCode(1_040_752_013, "该品种按托盘生成袋码,托盘数必须大于 0");
    ErrorCode PRO_BAG_CODE_BIND_BATCH_ITEM_MISMATCH = new ErrorCode(1_040_752_014, "录入批次的袋码必须属于同一品种");
    // ========== MES ç§å­æ ‡ç­¾å›½æ ‡å››è¦ç´ ï¼ˆ1-040-750-020) ==========
    ErrorCode PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED = new ErrorCode(1_040_750_020, "未配置追溯网址,无法生成二维码标签");
    ErrorCode PRO_QR_LABEL_ITEM_NAME_MISSING = new ErrorCode(1_040_750_021, "标签缺少品种名称,无法导出");
    ErrorCode PRO_QR_LABEL_OPERATOR_MISSING = new ErrorCode(1_040_750_022, "标签缺少生产经营者名称,无法导出");
    ErrorCode PRO_QR_LABEL_UNIT_CODE_MISSING = new ErrorCode(1_040_750_023, "标签缺少单元识别代码,无法导出");
    ErrorCode PRO_QR_LABEL_UNIT_CODE_INVALID = new ErrorCode(1_040_750_024, "单元识别代码不符合国标:长度不超过 20 ä¸ªå­—符,且只能为数字或字母数字组合");
    // ========== MES ç”Ÿäº§ç®¡ç†-托盘码(1-040-753-000) ==========
    ErrorCode PRO_PALLET_NOT_EXISTS = new ErrorCode(1_040_753_000, "托盘码不存在");
    ErrorCode PRO_PALLET_HAS_BAG_NOT_DELETE = new ErrorCode(1_040_753_001, "托盘码已绑定袋码,不允许删除");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/trace/MesTraceCodeStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.mes.enums.trace;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * è¿½æº¯ç æœ‰æ•ˆæ€§çŠ¶æ€æžšä¸¾ï¼ˆå…¬å¼€æº¯æºæŸ¥è¯¢ç»“æžœï¼‰
 * <p>
 * ä¾æ®ã€Šå†œä½œç‰©ç§å­æ ‡ç­¾äºŒç»´ç ç¼–码规则》,每个二维码对应唯一的最小销售单元且不得重复使用。
 * å·²ä½œåºŸçš„袋码(破损替换后作废)必须对外失效,否则作废码仍可被冒用销售,
 * å› æ­¤æº¯æºæŸ¥è¯¢éœ€æ˜Žç¡®åŒºåˆ†ã€Œæœ‰æ•ˆã€ä¸Žã€Œå·²ä½œåºŸã€ã€‚
 */
@Getter
@AllArgsConstructor
public enum MesTraceCodeStatusEnum {
    /**
     * æœ‰æ•ˆï¼šç å¤„于正常流转状态
     */
    VALID("VALID", "有效"),
    /**
     * å·²ä½œåºŸï¼šè¢‹ç å·²è¢«ç ´æŸæ›¿æ¢æµç¨‹ä½œåºŸï¼Œä¸å¾—作为有效产品流通
     */
    CANCELED("CANCELED", "已作废");
    /**
     * çŠ¶æ€ç¼–ç ï¼ˆå¯¹å¤–è¾“å‡ºï¼‰
     */
    private final String status;
    /**
     * çŠ¶æ€åç§°
     */
    private final String name;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesProperties.java
@@ -14,8 +14,36 @@
    /**
     * H5 æº¯æºé¡µé¢åœ°å€ï¼ˆtrace.html)基础 URL,用于生成携带追溯码参数的二维码内容。
     * ä¸ºç©ºæ—¶äºŒç»´ç ä»ç¼–码纯码值(兼容旧码)。
     * <p>
     * è¯¥å€¼ä¸ºã€Œè¿½æº¯ç½‘址」的唯一真值源:二维码内容与 H5 é¡µé¢å±•示的追溯网址均取自此配置。
     * å¿…须为公众扫码可访问的地址,非本地环境配置为内网/占位符地址时启动将直接失败。
     */
    private String traceBaseUrl = "";
    /**
     * ç”Ÿäº§ç»è¥è€…名称(种子标签四要素之一)。
     * <p>
     * ä½œä¸ºã€Œç”Ÿäº§ç»è¥è€…名称」的唯一真值源,标签印刷与 H5 æº¯æºé¡µå‡å–此值,禁止在前端硬编码。
     */
    private String operatorName = "";
    /**
     * è¿›å£å•†åç§°ï¼ˆè¿›å£ç§å­åœºæ™¯ï¼Œæ ‡ç­¾å››è¦ç´ ä¹‹ä¸€ï¼‰ã€‚
     * <p>
     * è¿›å£ç§å­åº”标注进口商名称;为空时回退使用 {@link #operatorName}。
     */
    private String importerName = "";
    /**
     * æ˜¯å¦å¤„于进口种子场景:为 true æ—¶æ ‡ç­¾ä¸Žæº¯æºé¡µå±•示「进口商名称」而非「生产经营者名称」
     */
    private boolean imported = false;
    /**
     * å…è®¸äºŒç»´ç ç¼–码纯码值(不带溯源 URL)。
     * <p>
     * ä»…供本地/测试环境兼容历史码使用;生产环境必须为 false,否则会印出不含追溯网址的标签。
     */
    private boolean allowPlainQrCode = false;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesQrCodeConfig.java
@@ -1,22 +1,142 @@
package cn.iocoder.yudao.module.mes.framework.config;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import java.net.URI;
import java.util.Arrays;
/**
 * MES äºŒç»´ç ç›¸å…³é…ç½®ï¼šå¯åŠ¨æ—¶å°† traceBaseUrl æ³¨å…¥äºŒç»´ç æ ‡ç­¾æž„建器,避免侵入 Controller è°ƒç”¨ç‚¹
 * MES äºŒç»´ç ç›¸å…³é…ç½®ï¼šå¯åŠ¨æ—¶æ ¡éªŒå¹¶æŠŠé…ç½®æ³¨å…¥äºŒç»´ç æ ‡ç­¾æž„å»ºå™¨ï¼Œé¿å…ä¾µå…¥ Controller è°ƒç”¨ç‚¹ã€‚
 * <p>
 * ç§å­æ ‡ç­¾å±žäºŽå—国标约束的合规物料,一旦印出即无法召回,因此这里采取「启动即校验、不合法即拒绝启动」
 * çš„策略,而不是等到导出时才失败或静默降级:
 * <ul>
 *     <li>追溯网址必须为公众可访问地址,禁止内网 IP / æœ¬æœºåœ°å€ / æœªæ›¿æ¢çš„占位符;</li>
 *     <li>生产经营者名称必填(标签四要素之一,缺失即为不合规标签);</li>
 *     <li>当前环境必须存在可渲染中文的字体(否则标签中文会变成方框)。</li>
 * </ul>
 */
@Slf4j
@Configuration(proxyBeanMethods = false)
public class MesQrCodeConfig {
    /**
     * æœ¬åœ°çŽ¯å¢ƒ profile åï¼šè¯¥ profile ä¸‹æ”¾å®½æ ¡éªŒï¼Œä¾¿äºŽæœ¬åœ°å¼€å‘调试
     */
    private static final String PROFILE_LOCAL = "local";
    @Resource
    private MesProperties mesProperties;
    @Resource
    private Environment environment;
    @PostConstruct
    public void init() {
        // 1. æ³¨å…¥é…ç½®ï¼ˆå­—体校验独立于业务配置,本地环境同样必须通过,否则标签中文不可读)
        MesProQrLabelBuilder.setTraceBaseUrl(mesProperties.getTraceBaseUrl());
        MesProQrLabelBuilder.setOperatorName(mesProperties.getOperatorName());
        MesProQrLabelBuilder.setImporterName(mesProperties.getImporterName());
        MesProQrLabelBuilder.setImported(mesProperties.isImported());
        MesProQrLabelBuilder.setAllowPlainQrCode(mesProperties.isAllowPlainQrCode());
        QrCodeImageUtils.assertCjkFontAvailable();
        // 2. éžæœ¬åœ°çŽ¯å¢ƒæ‰§è¡Œä¸¥æ ¼æ ¡éªŒ
        if (isLocalProfile()) {
            log.info("[init] å½“前为本地环境({}),跳过标签合规配置的严格校验;traceBaseUrl={}",
                    PROFILE_LOCAL, mesProperties.getTraceBaseUrl());
            return;
        }
        validateTraceBaseUrl();
        validateOperatorName();
        log.info("[init] äºŒç»´ç æ ‡ç­¾é…ç½®æ ¡éªŒé€šè¿‡ï¼›traceBaseUrl={},生产经营者={}",
                mesProperties.getTraceBaseUrl(), MesProQrLabelBuilder.resolveOperatorName());
    }
    private boolean isLocalProfile() {
        return Arrays.asList(environment.getActiveProfiles()).contains(PROFILE_LOCAL);
    }
    /**
     * æ ¡éªŒè¿½æº¯ç½‘址:必须为公众扫码可访问的地址
     */
    private void validateTraceBaseUrl() {
        String url = StrUtil.trim(mesProperties.getTraceBaseUrl());
        if (StrUtil.isEmpty(url)) {
            throw new IllegalStateException("mes.trace-base-url æœªé…ç½®ã€‚二维码标签必须包含追溯网址,"
                    + "请在配置中填写公众扫码可访问的 H5 æº¯æºé¡µåœ°å€ã€‚");
        }
        if (StrUtil.containsAnyIgnoreCase(url, "your-domain", "example.com", "localhost")) {
            throw new IllegalStateException("mes.trace-base-url ä»ä¸ºå ä½ç¬¦æˆ–本机地址:" + url
                    + ",印出的标签消费者将无法访问,请填写真实的外网地址。");
        }
        String host = extractHost(url);
        if (StrUtil.isEmpty(host)) {
            throw new IllegalStateException("mes.trace-base-url æ ¼å¼éžæ³•,无法解析主机名:" + url);
        }
        if (isPrivateHost(host)) {
            throw new IllegalStateException("mes.trace-base-url æŒ‡å‘内网地址(" + host + "):" + url
                    + ",公众扫码无法访问,请填写外网可访问的地址。");
        }
    }
    /**
     * æ ¡éªŒç”Ÿäº§ç»è¥è€…名称:标签四要素之一,缺失即为不合规标签
     * <p>
     * æ­¤å¤„仅告警不阻断启动:生产经营者名称按主体区分,部分环境可能尚未配置,
     * å¼ºåˆ¶å¯åŠ¨å¤±è´¥ä¼šå½±å“æ— å…³åŠŸèƒ½ã€‚çœŸæ­£çš„æ‹¦æˆªç‚¹åœ¨æ ‡ç­¾æž„å»ºå¤„ï¼ˆç¼ºå¤±æ—¶æ‹’ç»å¯¼å‡ºï¼‰ï¼Œ
     * å› æ­¤ä¸ä¼šå‡ºçް"配置缺失却印出不合规标签"的情况。
     */
    private void validateOperatorName() {
        if (StrUtil.isBlank(MesProQrLabelBuilder.resolveOperatorName())) {
            log.warn("[init] mes.operator-name(生产经营者名称)未配置。该字段为种子标签四要素之一,"
                    + "缺失时将无法导出二维码标签,请尽快在配置中补充。");
        }
    }
    /**
     * ä»Ž URL ä¸­æå–主机名
     */
    private String extractHost(String url) {
        try {
            URI uri = URI.create(url);
            return uri.getHost();
        } catch (IllegalArgumentException ex) {
            return null;
        }
    }
    /**
     * åˆ¤æ–­æ˜¯å¦ä¸ºæœ¬æœº / å†…网 / é“¾è·¯æœ¬åœ°åœ°å€
     */
    private boolean isPrivateHost(String host) {
        if ("localhost".equalsIgnoreCase(host) || host.endsWith(".local")) {
            return true;
        }
        String[] parts = host.split("\\.");
        if (parts.length != 4) {
            return false; // åŸŸåå½¢å¼ï¼Œäº¤ç”± DNS è§£æžï¼Œä¸åœ¨æ­¤å¤„拦截
        }
        int a, b;
        try {
            a = Integer.parseInt(parts[0]);
            b = Integer.parseInt(parts[1]);
        } catch (NumberFormatException ex) {
            return false;
        }
        // 127.0.0.0/8 æœ¬æœºã€10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 ç§ç½‘、169.254.0.0/16 é“¾è·¯æœ¬åœ°
        return a == 127
                || a == 10
                || (a == 172 && b >= 16 && b <= 31)
                || (a == 192 && b == 168)
                || (a == 169 && b == 254);
    }
}
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());
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java
@@ -28,7 +28,8 @@
/**
 * äºŒç»´ç æ ‡ç­¾å›¾ç‰‡å·¥å…·ç±»
 * <p>
 * ç”Ÿæˆ"二维码 + å³ä¾§å¯è¯»æ–‡æœ¬"的合成图片:二维码只编码纯码值,文本叠加关键信息,
 * ç”Ÿæˆ"二维码 + å³ä¾§å¯è¯»æ–‡æœ¬"的合成图片:二维码编码内容由调用方传入(配置了溯源地址时为
 * å¸¦ code å‚数的溯源 URL,未配置时为纯码值),右侧叠加关键信息文本,
 * å³ä½¿æ•°æ®åº“丢失也能从打印图片还原关键信息。
 */
@Slf4j
@@ -55,11 +56,21 @@
    /** å­—段间距 */
    private static final int FIELD_GAP = 6;
    /** ä¸­æ–‡å­—体候选(按优先级),避免 Linux headless ä¸‹æ–‡å­—变方框 */
    /**
     * ä¸­æ–‡å­—体候选(按优先级)。
     * <p>
     * åªå…è®¸æ”¶å½•可渲染中文的字体:纯拉丁字体(如 Arial)一旦入选,中文会全部渲染成方框,
     * ä¸”导出过程不报错,属于最隐蔽的一类事故。
     */
    private static final String[] FONT_CANDIDATES = {
            "Noto Sans CJK SC", "WenQuanYi Zen Hei", "Microsoft YaHei", "SimSun", "Arial"
            "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Zen Hei", "PingFang SC",
            "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", "SimSun"
    };
    /** ä¸­æ–‡å­—形探针字符(用于探测字体是否具备中文字形) */
    private static final char CJK_PROBE = '袋';
    /** è§£æžå‡ºçš„中文字体族名;为 null è¡¨ç¤ºå½“前环境没有任何可渲染中文的字体 */
    private static final String FONT_NAME = resolveFontName();
    /**
@@ -70,6 +81,8 @@
     * @return åˆæˆå›¾ç‰‡
     */
    public static BufferedImage createQrLabel(String code, LinkedHashMap<String, String> keyValues) {
        // æ— ä¸­æ–‡å­—体时直接失败,避免导出中文为方框的标签
        assertCjkFontAvailable();
        QrConfig config = new QrConfig();
        config.setErrorCorrection(ErrorCorrectionLevel.H); // æ‰“印标签易磨损,用高容错
        config.setMargin(1); // ä¿è¯é™åŒºï¼Œæ‰«ç çއ
@@ -134,14 +147,46 @@
    // ==================== ç§æœ‰æ–¹æ³• ====================
    /**
     * è§£æžå½“前环境可用的中文字体族名。
     * <p>
     * ä¸èƒ½æŒ‰å­—体族名做精确匹配:同一字体在不同系统上的族名可能不同(例如 Windows ä¸Šå®žé™…注册为
     * {@code Microsoft YaHei UI} è€Œéž {@code Microsoft YaHei}),精确匹配落空后会一路退到纯拉丁字体,
     * ä¸­æ–‡å³æ¸²æŸ“为方框。因此这里统一用 {@link Font#canDisplay(char)} æŽ¢æµ‹ä¸­æ–‡å­—形。
     *
     * @return å¯æ¸²æŸ“中文的字体族名;无可用字体时返回 {@code null}
     */
    private static String resolveFontName() {
        Set<String> families = Set.of(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames());
        for (String candidate : FONT_CANDIDATES) {
            if (families.contains(candidate)) {
            if (families.contains(candidate) && canDisplayCjk(candidate)) {
                return candidate;
            }
        }
        return Font.SANS_SERIF;
        // é€»è¾‘字体由 JDK è´Ÿè´£å­—形回退,多数环境可渲染中文
        if (canDisplayCjk(Font.SANS_SERIF)) {
            return Font.SANS_SERIF;
        }
        return null;
    }
    /**
     * æŽ¢æµ‹æŒ‡å®šå­—体族能否渲染中文
     */
    private static boolean canDisplayCjk(String family) {
        return new Font(family, Font.PLAIN, FONT_SIZE).canDisplay(CJK_PROBE);
    }
    /**
     * æ ¡éªŒå½“前环境存在可渲染中文的字体,否则抛出异常。
     * <p>
     * ä¾›å¯åŠ¨æœŸè°ƒç”¨ï¼šå®å¯åœ¨å¯åŠ¨æ—¶å¤±è´¥ï¼Œä¹Ÿä¸èƒ½æŠŠä¸­æ–‡æ¸²æŸ“æˆæ–¹æ¡†çš„æ ‡ç­¾äº¤ä»˜å°åˆ·ã€‚
     */
    public static void assertCjkFontAvailable() {
        if (FONT_NAME == null) {
            throw new IllegalStateException("当前环境没有任何可渲染中文的字体,二维码标签的中文将显示为方框,"
                    + "拒绝生成标签。请在服务器安装中文字体(如 fonts-noto-cjk)后重启。");
        }
    }
    private static int measureTextHeight(LinkedHashMap<String, String> keyValues, Font labelFont, Font valueFont) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeService.java
@@ -140,6 +140,25 @@
    List<MesProBagCodeDO> getBagCodeListByBatchId(Long batchId);
    /**
     * èŽ·å¾—æŸæ‰¹æ¬¡ä¸‹å¯å°åˆ·çš„è¢‹ç åˆ—è¡¨ï¼ˆæŽ’é™¤å·²ä½œåºŸè¢‹ç ï¼ŒæŒ‰è¢‹åºå·å‡åºï¼‰ï¼Œç”¨äºŽæ‰¹é‡å¯¼å‡ºäºŒç»´ç æ ‡ç­¾
     * <p>
     * å·²ä½œåºŸçš„袋码(破损替换后作废)不得再印刷:其标签若贴到产品上,消费者扫码会被提示
     * ã€Œè¯¥è¿½æº¯ç å·²ä½œåºŸï¼Œè°¨é˜²å‡å†’」,导致真品被误判为假货。
     *
     * @param batchId æ‰¹æ¬¡ç¼–号
     * @return å¯å°åˆ·çš„袋码列表
     */
    List<MesProBagCodeDO> getPrintableBagCodeListByBatchId(Long batchId);
    /**
     * èŽ·å¾—æŸç”Ÿæˆç»„ä¸‹å¯å°åˆ·çš„è¢‹ç åˆ—è¡¨ï¼ˆæŽ’é™¤å·²ä½œåºŸè¢‹ç ï¼‰ï¼Œç”¨äºŽæ‰¹é‡å¯¼å‡ºäºŒç»´ç æ ‡ç­¾
     *
     * @param uuid ç”Ÿæˆç»„ UUID
     * @return å¯å°åˆ·çš„袋码列表
     */
    List<MesProBagCodeDO> getPrintableBagCodeListByUuid(String uuid);
    /**
     * æ ‡è®°è¢‹ç ä¸ºå·²å‡ºåº“(记录销售出库单编号和出库时间)
     *
     * @param id      è¢‹ç ç¼–号
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java
@@ -128,7 +128,8 @@
            }
            palletMapper.insertBatch(pallets);
        }
        int startNo = bagCodeMapper.selectMaxBagNo() + 1;
        // è¢‹åºå·åŽŸå­åˆ†é…ï¼šå…ˆå–åº“å†…æœ€å¤§åºå·ä½œåŸºå‡†ï¼Œå†ç”± Redis åŽŸå­è‡ªå¢žï¼Œé¿å…å¹¶å‘ç”Ÿæˆäº§ç”Ÿé‡å¤è¢‹åºå·
        int startNo = codeRedisDao.allocateBagNoStart(totalCount, bagCodeMapper.selectMaxBagNo());
        List<MesProBagCodeDO> bagCodes = new ArrayList<>(totalCount);
        for (int i = 0; i < totalCount; i++) {
            int bagNo = startNo + i;
@@ -160,7 +161,8 @@
        // åŽŸå§‹è¢‹ç ä¸ä¾èµ–è®¡é‡å•ä½ç±»åž‹ä¸Žæ‰˜ç›˜ï¼šä»…æŒ‰æ•°é‡ç”Ÿæˆï¼Œè¢‹ç å¸¦å“ç§å†—ä½™ï¼ŒåŽç»­å¯æ­£å¸¸å½•å…¥æ‰¹æ¬¡/绑定托盘
        MesMdmItemDO item = mdmItemService.validateItemExists(generateReqVO.getItemId());
        String uuid = IdUtil.fastSimpleUUID();
        int startNo = bagCodeMapper.selectMaxBagNo() + 1;
        // è¢‹åºå·åŽŸå­åˆ†é…ï¼šå…ˆå–åº“å†…æœ€å¤§åºå·ä½œåŸºå‡†ï¼Œå†ç”± Redis åŽŸå­è‡ªå¢žï¼Œé¿å…å¹¶å‘ç”Ÿæˆäº§ç”Ÿé‡å¤è¢‹åºå·
        int startNo = codeRedisDao.allocateBagNoStart(generateReqVO.getCount(), bagCodeMapper.selectMaxBagNo());
        List<MesProBagCodeDO> bagCodes = new ArrayList<>(generateReqVO.getCount());
        for (int i = 0; i < generateReqVO.getCount(); i++) {
            int bagNo = startNo + i;
@@ -532,6 +534,16 @@
    }
    @Override
    public List<MesProBagCodeDO> getPrintableBagCodeListByBatchId(Long batchId) {
        return bagCodeMapper.selectPrintableListByBatchId(batchId);
    }
    @Override
    public List<MesProBagCodeDO> getPrintableBagCodeListByUuid(String uuid) {
        return bagCodeMapper.selectPrintableListByUuid(uuid);
    }
    @Override
    public void markOutbound(Long id, Long salesId) {
        bagCodeMapper.markOutbound(id, salesId, LocalDateTime.now());
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/trace/MesProTracePublicService.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.mes.service.trace;
import cn.iocoder.yudao.module.mes.controller.pub.trace.vo.MesProTracePublicConfigRespVO;
import cn.iocoder.yudao.module.mes.controller.pub.trace.vo.MesProTracePublicRespVO;
/**
@@ -18,4 +19,14 @@
     */
    MesProTracePublicRespVO queryTrace(String code);
    /**
     * èŽ·å¾— H5 æº¯æºé¡µé…ç½®ï¼ˆç”Ÿäº§ç»è¥è€…名称、追溯网址)
     * <p>
     * ä¸¤é¡¹å‡ä¸ºç§å­æ ‡ç­¾å››è¦ç´ çš„组成部分,由后端统一下发,保证二维码内容、标签印刷内容与
     * H5 é¡µé¢å±•示一致。
     *
     * @return é¡µé¢é…ç½®
     */
    MesProTracePublicConfigRespVO getPublicConfig();
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/trace/MesProTracePublicServiceImpl.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.module.mes.controller.admin.wm.batch.vo.MesWmBatchProcessTraceRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.batch.vo.MesWmBatchPurchaseSourceRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.batch.vo.MesWmBatchSalesTargetRespVO;
import cn.iocoder.yudao.module.mes.controller.pub.trace.vo.MesProTracePublicConfigRespVO;
import cn.iocoder.yudao.module.mes.controller.pub.trace.vo.MesProTracePublicRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
@@ -18,6 +19,9 @@
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBatchStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBatchWorkerTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum;
import cn.iocoder.yudao.module.mes.enums.trace.MesTraceCodeStatusEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
@@ -112,8 +116,8 @@
            if (entry.batchId == null && entry.bagItemId == null) {
                throw exception(MES_TRACE_CODE_NOT_FOUND);
            }
            // 2. èšåˆå…¨ç¨‹ä¿¡æ¯
            MesProTracePublicRespVO result = buildRespVO(rawCode, traceCode, entry.sourceType, entry.batchId, entry.bagItemId);
            // 2. èšåˆå…¨ç¨‹ä¿¡æ¯ï¼ˆä½œåºŸç ä»æ­£å¸¸è¿”回并记录扫码:作废码被扫是重要的防伪信号,不应记为查询失败)
            MesProTracePublicRespVO result = buildRespVO(rawCode, traceCode, entry);
            recordConsumerScan(rawCode, traceCode, entry.sourceType, entry.batchId, true);
            return result;
        } catch (RuntimeException ex) {
@@ -121,6 +125,15 @@
                    entry == null ? null : entry.batchId, false);
            throw ex;
        }
    }
    @Override
    public MesProTracePublicConfigRespVO getPublicConfig() {
        MesProTracePublicConfigRespVO respVO = new MesProTracePublicConfigRespVO();
        respVO.setOperatorName(MesProQrLabelBuilder.resolveOperatorName());
        respVO.setTraceUrl(MesProQrLabelBuilder.resolveTraceUrl());
        respVO.setImported(MesProQrLabelBuilder.isImported());
        return respVO;
    }
    /**
@@ -173,7 +186,13 @@
        // â‘  è¢‹ç 
        MesProBagCodeDO bag = bagCodeService.getBagCodeByCode(traceCode);
        if (bag != null) {
            return new TraceEntry("BAG", bag.getBatchId(), bag.getItemId());
            // å·²ä½œåºŸçš„袋码(破损替换后作废)必须对外失效:国标要求二维码不得重复使用,
            // è‹¥ä½œåºŸç ä»è¿”回有效溯源信息,作废码即可被冒用销售
            if (MesProBagCodeStatusEnum.CANCELED.getStatus().equals(bag.getStatus())) {
                return new TraceEntry("BAG", bag.getBatchId(), bag.getItemId(),
                        MesTraceCodeStatusEnum.CANCELED.getStatus());
            }
            return TraceEntry.valid("BAG", bag.getBatchId(), bag.getItemId());
        }
        // â‘¡ æ‰˜ç›˜ç 
        MesProPalletDO pallet = palletService.getPalletByCode(traceCode);
@@ -181,19 +200,30 @@
            // æœªå½•入批次的托盘取托盘内任一袋码品种,保证仍能展示品种信息
            Long itemId = pallet.getBatchId() == null
                    ? firstItemId(bagCodeService.getBagCodeListByPalletId(pallet.getId())) : null;
            return new TraceEntry("PALLET", pallet.getBatchId(), itemId);
            if (MesProPalletStatusEnum.CANCELED.getStatus().equals(pallet.getStatus())) {
                return new TraceEntry("PALLET", pallet.getBatchId(), itemId,
                        MesTraceCodeStatusEnum.CANCELED.getStatus());
            }
            return TraceEntry.valid("PALLET", pallet.getBatchId(), itemId);
        }
        // â‘¢ æ‰¹æ¬¡ç ï¼ˆå«ä¸´æ—¶æ‰¹æ¬¡ç ï¼ŒgetBatchByCode å†…部兼容)
        MesProBatchDO batch = batchService.getBatchByCode(traceCode);
        if (batch != null) {
            return new TraceEntry("BATCH", batch.getId(), null);
            if (MesProBatchStatusEnum.CANCELED.getStatus().equals(batch.getStatus())) {
                return new TraceEntry("BATCH", batch.getId(), null,
                        MesTraceCodeStatusEnum.CANCELED.getStatus());
            }
            return TraceEntry.valid("BATCH", batch.getId(), null);
        }
        return new TraceEntry("", null, null);
        return new TraceEntry("", null, null, MesTraceCodeStatusEnum.VALID.getStatus());
    }
    // ==================== èšåˆæž„建 ====================
    private MesProTracePublicRespVO buildRespVO(String requestCode, String traceCode, String sourceType, Long batchId, Long bagItemId) {
    private MesProTracePublicRespVO buildRespVO(String requestCode, String traceCode, TraceEntry entry) {
        String sourceType = entry.sourceType();
        Long batchId = entry.batchId();
        Long bagItemId = entry.bagItemId();
        MesProBatchDO batch = batchId == null ? null : batchService.getBatch(batchId);
        if (batchId != null && batch == null) {
            throw exception(MES_TRACE_CODE_NOT_FOUND);
@@ -201,6 +231,10 @@
        MesProTracePublicRespVO resp = new MesProTracePublicRespVO();
        resp.setSourceType(sourceType);
        resp.setRequestCode(requestCode);
        // è¿½æº¯ç æœ‰æ•ˆæ€§ + æ ‡ç­¾å››è¦ç´ ä¸­çš„生产经营者与追溯网址,保证与标签印刷内容一致
        resp.setCodeStatus(entry.codeStatus());
        resp.setOperatorName(MesProQrLabelBuilder.resolveOperatorName());
        resp.setTraceUrl(MesProQrLabelBuilder.resolveTraceUrl());
        // å“ç§åŒºå—
        fillItem(resp, batch, bagItemId);
        if (batch == null) {
@@ -505,7 +539,20 @@
        };
    }
    private record TraceEntry(String sourceType, Long batchId, Long bagItemId) {
    /**
     * è¿½æº¯å…¥å£è§£æžç»“æžœ
     *
     * @param sourceType å…¥å£ç±»åž‹ï¼šBAG / PALLET / BATCH,未匹配到时为空串
     * @param batchId    ç”Ÿäº§æ‰¹æ¬¡ç¼–号
     * @param bagItemId  è¢‹ç ç»‘定的品种编号(未录批次时用于展示品种)
     * @param codeStatus è¿½æº¯ç æœ‰æ•ˆæ€§ï¼š{@link MesTraceCodeStatusEnum#VALID} / {@link MesTraceCodeStatusEnum#CANCELED}
     */
    private record TraceEntry(String sourceType, Long batchId, Long bagItemId, String codeStatus) {
        static TraceEntry valid(String sourceType, Long batchId, Long bagItemId) {
            return new TraceEntry(sourceType, batchId, bagItemId, MesTraceCodeStatusEnum.VALID.getStatus());
        }
    }
}
yudao-server/src/main/resources/application-local.yaml
@@ -1,9 +1,12 @@
server:
  port: 48080
# H5 æº¯æºé¡µé¢åŸºç¡€åœ°å€ï¼ˆäºŒç»´ç å†…容前缀),需为公众扫码可访问的地址
# ç§å­æ ‡ç­¾ä¸Ž H5 æº¯æºé¡µé…ç½®
mes:
  # è¿½æº¯ç½‘址(二维码内容前缀),需为公众扫码可访问的地址
  trace-base-url: http://192.168.1.7:48080/trace.html
  # ç”Ÿäº§ç»è¥è€…名称(种子标签四要素之一,标签与溯源页统一取此值;未配置则无法导出标签)
  operator-name: æ–°ç–†å¡”里木河种业股份有限公司
--- #################### æ•°æ®åº“相关配置 ####################
spring:
yudao-server/src/main/resources/application-thzy.yaml
@@ -1,9 +1,12 @@
server:
  port: 48080
# H5 æº¯æºé¡µé¢åŸºç¡€åœ°å€ï¼ˆäºŒç»´ç å†…容前缀),需为公众扫码可访问的地址
# ç§å­æ ‡ç­¾ä¸Ž H5 æº¯æºé¡µé…ç½®
mes:
  # è¿½æº¯ç½‘址(二维码内容前缀),需为公众扫码可访问的地址
  trace-base-url: http://1.95.78.124:9008/prod/trace.html
  # ç”Ÿäº§ç»è¥è€…名称(种子标签四要素之一,标签与溯源页统一取此值)
  operator-name: æ–°ç–†å¡”里木河种业股份有限公司
--- #################### æ•°æ®åº“相关配置 ####################
spring:
yudao-server/src/main/resources/application.yaml
@@ -1,7 +1,17 @@
# H5 æº¯æºé¡µé¢åŸºç¡€åœ°å€ï¼ˆäºŒç»´ç å†…容前缀,形如 http://域名:端口/trace.html),
# éœ€ä¸ºå…¬ä¼—扫码可访问的地址;各 profile å¯è¦†ç›–。留空则二维码编码纯码值(兼容旧码)。
# ç§å­æ ‡ç­¾ä¸Ž H5 æº¯æºé¡µé…ç½®ï¼ˆå„ profile å¯è¦†ç›–)
# æ³¨æ„ï¼štrace-base-url ä¸Ž operator-name æ˜¯äºŒç»´ç æ ‡ç­¾å››è¦ç´ ä¸­çš„「追溯网址」「生产经营者名称」,
# éž local çŽ¯å¢ƒä¸‹ trace-base-url è‹¥ä¸ºå†…网/占位符地址将拒绝启动,避免印出消费者扫不开的标签。
mes:
  # è¿½æº¯ç½‘址(二维码内容前缀,形如 http://域名:端口/trace.html),需为公众扫码可访问的地址
  trace-base-url: http://localhost:48080/trace.html
  # ç”Ÿäº§ç»è¥è€…名称(标签四要素之一,标签与溯源页统一取此值;未配置则无法导出标签)
  operator-name: ''
  # è¿›å£å•†åç§°ï¼ˆè¿›å£ç§å­åœºæ™¯å¡«å†™ï¼›imported=true æ—¶æ ‡ç­¾ä¸Žæº¯æºé¡µå±•示进口商名称)
  importer-name: ''
  # æ˜¯å¦è¿›å£ç§å­åœºæ™¯
  imported: false
  # æ˜¯å¦å…è®¸äºŒç»´ç ç¼–码纯码值(不带溯源 URL),仅供本地兼容历史码,生产环境必须为 false
  allow-plain-qr-code: false
spring:
  application:
yudao-server/src/main/resources/static/trace.html
@@ -347,11 +347,26 @@
(function () {
  'use strict';
  // ========== é¡µé¢å¯é…ç½®é¡¹ ==========
  // ç”Ÿäº§ç»è¥è€…(追溯查询区块展示)
  var COMPANY_NAME = '新疆塔里木河种业股份有限公司';
  // è¿½æº¯ç½‘址(外网访问地址,与包装/二维码上印制的一致)
  var TRACE_URL = 'http://1.95.78.124:9008/';
  // ========== é¡µé¢é…ç½® ==========
  // ç”Ÿäº§ç»è¥è€…名称与追溯网址是种子标签四要素的组成部分,必须与二维码/标签印刷内容一致,
  // å› æ­¤ç»Ÿä¸€ç”±åŽç«¯ä¸‹å‘(/public/mes/trace/config),禁止在此硬编码。
  var COMPANY_NAME = '';
  var TRACE_URL = '';
  var IS_IMPORTED = false;
  // æ‹‰å–溯源页配置;失败时不阻断查询(查询结果本身也带生产经营者与追溯网址)
  function loadTraceConfig() {
    return fetch('/public/mes/trace/config')
      .then(function (resp) { return resp.json(); })
      .then(function (res) {
        if (res && res.code === 0 && res.data) {
          COMPANY_NAME = res.data.operatorName || '';
          TRACE_URL = res.data.traceUrl || '';
          IS_IMPORTED = !!res.data.imported;
        }
      })
      .catch(function () { /* é…ç½®æ‹‰å–失败:退回使用查询结果中的同名字段 */ });
  }
  var STATE = document.getElementById('state');
  var CODE_INPUT = document.getElementById('codeInput');
@@ -477,11 +492,19 @@
      renderState('<div class="empty-tip error">未查询到该追溯码,请核对二维码或联系官方</div>');
      return;
    }
    // å·²ä½œåºŸçš„追溯码(破损替换后作废)不得作为有效产品流通,直接给出警示并停止展示溯源详情
    if (d.codeStatus === 'CANCELED') {
      renderState('<div class="empty-tip error">该追溯码已作废,非本公司有效产品,谨防假冒。<br>'
        + '如有疑问请联系官方核实。</div>');
      return;
    }
    var html = '';
    html += sec('防伪查询', null, fakePanel(d), null);
    html += sec('追溯查询', null, tracePanel(d), null);
    // ç”Ÿäº§æ˜Žç»†æŠ˜å åŒºï¼ˆæ‰¹æ¬¡/质检/人员/托盘/袋码/采购来源/种子流向/加工流转)按需求不展示,
    // detailSections ä¿ç•™ä»¥ä¾¿åŽç»­æŒ‰éœ€å¼€å¯
    // ç”Ÿäº§æ˜Žç»†æŠ˜å åŒºï¼šå›½æ ‡è¦æ±‚追溯网址可查询「种子加工批次」及「物流销售信息」,故对外展示
    // ï¼ˆåŠ å·¥æ‰¹æ¬¡ã€ç§å­æµå‘ã€åŠ å·¥æµè½¬ï¼›æŠ˜å ä¸å±•å¼€ï¼‰ã€‚è´¨æ£€/人员/托盘/袋码/采购来源等内部明细不对外展示,
    // å¦‚需一并展示,改为 detailSections(d, true) å³å¯ã€‚
    html += detailSections(d, false);
    STATE.innerHTML = html || '<div class="empty-tip">暂无追溯数据</div>';
    window.scrollTo({ top: 0, behavior: 'smooth' });
  }
@@ -537,9 +560,19 @@
  }
  // åŒºå—2:追溯查询(未录入批次时只展示品种信息 + æç¤ºï¼‰
  // å››è¦ç´ ï¼ˆå“ç§åç§° â†’ ç”Ÿäº§ç»è¥è€… â†’ å•元识别代码 â†’ è¿½æº¯ç½‘址)按国标顺序排列,其余信息排在其后
  function tracePanel(d) {
    var code = resolveCode(d);
    var name = [d.itemName, d.itemSpecification].filter(Boolean).join(' ');
    // ç”Ÿäº§ç»è¥è€…与追溯网址优先取查询结果(与标签印刷内容同源),退回页面配置
    var operatorName = d.operatorName || COMPANY_NAME;
    var traceUrl = d.traceUrl || TRACE_URL;
    var operatorLabel = IS_IMPORTED ? '进口商名称' : '生产经营者';
    // åªæœ‰è¢‹ç æ‰æ˜¯ã€Œæœ€å°é”€å”®å•元」,其码值才称「单元识别代码」;
    // æ‰˜ç›˜ç /批次码不属于最小销售单元标识,按实际类型展示,避免误导消费者
    var codeLabel = d.sourceType === 'BAG'
      ? '单元识别代码'
      : (SOURCE_TYPE[d.sourceType] ? SOURCE_TYPE[d.sourceType] + '(非最小销售单元)' : '追溯码');
    var unbound = d.batchBound === false;
    var batchRows = unbound
      ? '<div class="notice">该追溯码尚未录入生产批次,批次号、生产日期、产线等信息待完善;当前展示该码对应的品种信息。</div>'
@@ -547,31 +580,40 @@
        kv('生产日期', fmtDate(d.produceDate)) +
        kv('产线', text(d.lineName || d.lineCode));
    return kv('品种名称', text(name)) +
      kv(operatorLabel, text(operatorName)) +
      kv(codeLabel, text(code || '-')) +
      kv('追溯网址', traceUrl
        ? '<a class="link" href="' + esc(traceUrl) + '">' + esc(traceUrl) + '</a>'
        : '-') +
      kv('计量单位', text(d.itemUnitMeasureName)) +
      kv('生产经营者', esc(COMPANY_NAME)) +
      kv('单元识别代码', text(code || '-')) +
      kv('追溯网址', '<a class="link" href="' + esc(TRACE_URL) + '">' + esc(TRACE_URL) + '</a>') +
      kv('追溯类型', esc(SOURCE_TYPE[d.sourceType] || '追溯码')) +
      batchRows;
  }
  // åŒºå—3~:生产明细(折叠,有数据才展示)
  function detailSections(d) {
  // full=true æ—¶å±•示全部明细(质检/人员/托盘/袋码/采购来源);
  // é»˜è®¤åªå±•示国标要求的「加工批次」与「物流销售(种子流向)」信息,其余区块不对外展示。
  function detailSections(d, full) {
    var html = '';
    // ç”Ÿäº§æ‰¹æ¬¡ä¿¡æ¯
    var batchBody =
      kv('批次状态', d.batchStatusName ? statusBadge({ v: [d.batchStatusName, 'blue'] }, 'v') : text(d.batchStatus)) +
      kv('计划数量', fmtNum(d.planQuantity)) +
      kv('已生成袋码', fmtNum(d.bagQuantity)) +
      kv('已生成托盘', fmtNum(d.palletQuantity)) +
      kv('入库时间', fmtDate(d.inboundTime)) +
      kv('临时批次码', text(d.tempCode)) +
      kv('备注', text(d.remark));
    html += sec('生产批次信息', null, batchBody, { collapsible: true, open: false });
    // ç”Ÿäº§æ‰¹æ¬¡ä¿¡æ¯ï¼ˆå›½æ ‡è¦æ±‚:追溯网址应可查询种子加工批次)
    // æœªå½•入批次时不展示空区块,避免给出"无加工批次信息"的错误暗示
    if (d.batchBound !== false) {
      var batchBody =
        kv('批次号', text(d.batchCode)) +
        kv('批次状态', d.batchStatusName ? statusBadge({ v: [d.batchStatusName, 'blue'] }, 'v') : text(d.batchStatus)) +
        kv('生产日期', fmtDate(d.produceDate)) +
        kv('产线', text(d.lineName || d.lineCode)) +
        kv('计划数量', fmtNum(d.planQuantity)) +
        kv('已生成袋码', fmtNum(d.bagQuantity)) +
        kv('已生成托盘', fmtNum(d.palletQuantity)) +
        kv('入库时间', fmtDate(d.inboundTime)) +
        kv('备注', text(d.remark));
      html += sec('生产批次信息', null, batchBody, { collapsible: true, open: false });
    }
    // è´¨æ£€ä¿¡æ¯
    if (d.quality) {
    // è´¨æ£€ä¿¡æ¯ï¼ˆä»…完整模式展示)
    if (full && d.quality) {
      var q = d.quality;
      var qBody =
        kv('质检结论', q.resultName ? '<span class="badge ' + (q.result === 1 ? 'green' : 'red') + '">' + esc(q.resultName) + '</span>' : '-') +
@@ -596,8 +638,8 @@
      html += sec('质检信息', null, qBody, { collapsible: true, open: false });
    }
    // ç”Ÿäº§äººå‘˜
    if (!isEmptyList(d.workers)) {
    // ç”Ÿäº§äººå‘˜ï¼ˆä»…完整模式展示:涉及个人信息,不对外公开)
    if (full && !isEmptyList(d.workers)) {
      var wBody = d.workers.map(function (w) {
        return li('<span class="li-ttl">' + text(w.userName) + '</span>' +
          (w.workerTypeName ? '<span class="badge gray">' + esc(w.workerTypeName) + '</span>' : ''),
@@ -607,8 +649,8 @@
      html += sec('生产人员', d.workers.length + ' äºº', '<div class="scroll-list">' + wBody + '</div>', { collapsible: true, open: false });
    }
    // æ‰˜ç›˜ä¿¡æ¯
    if (!isEmptyList(d.pallets)) {
    // æ‰˜ç›˜ä¿¡æ¯ï¼ˆä»…完整模式展示)
    if (full && !isEmptyList(d.pallets)) {
      var pRows = d.pallets.map(function (p) {
        return [text(p.code), fmtNum(p.palletNo), fmtNum(p.bagCount), fmtNum(p.capacity)];
      });
@@ -617,8 +659,8 @@
        { collapsible: true, open: false });
    }
    // è¢‹ç åˆ—表
    if (!isEmptyList(d.bags)) {
    // è¢‹ç åˆ—表(仅完整模式展示)
    if (full && !isEmptyList(d.bags)) {
      var bBody = d.bags.map(function (b) {
        return li('<span class="li-ttl">' + text(b.code) + '</span>' +
          (b.statusName ? '<span class="badge gray">' + esc(b.statusName) + '</span>' : ''),
@@ -630,8 +672,8 @@
      html += sec('袋码列表', bagCountText, '<div class="scroll-list">' + bBody + '</div>', { collapsible: true, open: false });
    }
    // é‡‡è´­æ¥æº
    if (!isEmptyList(d.purchaseSources)) {
    // é‡‡è´­æ¥æºï¼ˆä»…完整模式展示)
    if (full && !isEmptyList(d.purchaseSources)) {
      var psRows = d.purchaseSources.map(function (s) {
        return [text(s.receiptCode), text(s.vendorName), nw(fmtNum(s.receivedQuantity)), nw(fmtDay(s.receiptDate))];
      });
@@ -697,6 +739,9 @@
    });
  })();
  // å…ˆæ‹‰å–页面配置(生产经营者名称、追溯网址),再执行查询;查询结果中同样带有这两项,互为兜底
  loadTraceConfig();
  var initialCode = new URLSearchParams(location.search).get('code') || '';
  CODE_INPUT.value = initialCode;
  if (initialCode) {