云
2 天以前 251be431ad12399fe4ebce599c353d05dcec7888
feat(mes): 支持追溯二维码解析功能

- 集成 MesTraceCodeUtils 工具类用于提取追溯码中的码值
- 在生产扫码事件服务中统一解析追溯二维码内容,提取码值进行处理
- 更新产品销售服务中的扫码扫描逻辑以支持二维码解析
- 优化库存盘点任务中追溯码的解析流程
- 扩展移动端菜单适配器支持更多仓库操作页面映射
已添加2个文件
已修改6个文件
357 ■■■■■ 文件已修改
docs/追溯二维码扫码业务操作方案.md 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/trace/MesProTraceResolveController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesTraceCodeUtils.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/scan/MesProScanEventServiceImpl.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/stocktaking/task/MesWmStockTakingTaskServiceImpl.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapter.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapterTest.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/×·ËݶþάÂëɨÂëÒµÎñ²Ù×÷·½°¸.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,242 @@
# è¿½æº¯äºŒç»´ç æ‰«ç ä¸šåŠ¡æ“ä½œæ–¹æ¡ˆ
版本:V1(待确认)
日期:2026-10-29
状态:需求方案,未开始编码
## 1. èƒŒæ™¯ä¸Žç›®æ ‡
现场标签上印的是**追溯二维码**,码内容不是纯码值,而是一条追溯网址:
```
{mes.trace-base-url}?code={追溯码}
```
例如 `http://1.95.78.124:9008/prod/trace.html?code=0286267849370923`。
需求:**浏览器移动端(/mobile)和 PC ç«¯ï¼ˆç®¡ç†åŽå°ï¼‰éƒ½èƒ½æ‰«æè¿½æº¯äºŒç»´ç ï¼Œç›´æŽ¥å¸¦å‡ºä¸šåŠ¡æ˜Žç»†å¹¶æ‰§è¡Œå…¥åº“ã€å‡ºåº“ã€ç§»åº“ã€ç›˜ç‚¹ã€é€€è´§ç­‰ä¸šåŠ¡æ“ä½œ**,减少手工录入物料、批次和数量。
本期只讨论"追溯二维码 / è¿½æº¯ç "这条主路径。业务码、临时码保持兼容,不作为主要入口。
## 2. ç å†…容与解析规则
| æ¥æº | å†…容形式 | è§£æžæ–¹å¼ |
|------|----------|----------|
| è¿½æº¯äºŒç»´ç ï¼ˆè¢‹ç  / æ‰˜ç›˜ç  / æ‰¹æ¬¡ç ï¼‰ | `http(s)://...?code=xxxx` | å– `code` æŸ¥è¯¢å‚æ•° |
| çº¯è¿½æº¯ç  | 16 ä½æ•°å­— | åŽŸæ ·ä½¿ç”¨ |
| ä¸šåŠ¡ç  | 16 ä½æ•°å­— | åŽŸæ ·ä½¿ç”¨ï¼ˆå…¼å®¹ï¼‰ |
| ä¸´æ—¶ç ï¼ˆæ‰˜ç›˜ / æ‰¹æ¬¡ï¼‰ | ä¸´æ—¶ç¼–码 | åŽŸæ ·ä½¿ç”¨ï¼ˆå…¼å®¹ï¼‰ |
统一规则(前后端各实现一份,口径必须一致):
1. åŽ»é™¤é¦–å°¾ç©ºç™½ï¼›
2. ä»¥ `http://` æˆ– `https://` å¼€å¤´æ—¶ï¼Œè§£æž URL å¹¶å– `code` å‚数;取不到则报"该链接不包含追溯码";
3. å…¶ä½™æƒ…况按原值处理;
4. ç å€¼é•¿åº¦ä¸è¶…过 64、不含空白字符,否则报"码值格式不正确";
5. å‰ç«¯å·²æå–过的码值传到后端时,后端再做一次同样的提取,避免扫码枪直接把整段 URL æäº¤è¿›æŽ¥å£ã€‚
移动端已有等价实现(`src/mobile/access.ts` çš„ `extractCode`),需要提升为前后端共用的公共逻辑,PC ç«¯çŽ°æœ‰æ‰«ç æ¡†æ²¡æœ‰è¿™ä¸€æ­¥ï¼Œå¿…é¡»è¡¥é½ã€‚
## 3. çŽ°çŠ¶æ ¸æŸ¥
| ç«¯ | çŽ°æœ‰èƒ½åŠ› | ç¼ºå£ |
|----|----------|------|
| ç§»åŠ¨ç«¯ | æ‘„像头扫码 + æ‰‹åŠ¨è¾“å…¥ï¼ˆ`mobile/scanner.vue`,getUserMedia + jsQR);已能从追溯网址提取 code;已覆盖扫码入库、袋码/托盘/批次查询绑定、种子质检、销售出库拣货 | æœªè¦†ç›–盘点、移库、退货等业务;各页面各自维护扫码逻辑 |
| PC ç«¯ | é”€å”®å‡ºåº“拣货、盘点执行有输入框(可接扫码枪) | æ— æ‘„像头扫码;输入框直接使用原始文本,扫追溯二维码会失败;其余 10 ä¸ªä»“储单据无扫码带出入口 |
| åŽç«¯ | `/mes/pro/trace/resolve-type` è§£æžç ç±»åž‹ï¼›è¢‹/托盘/批次 `get-by-code`;销售出库 `scan`、`scan-preview`;盘点 `scan`;扫码入库 `inbound` | å„接口都按纯码值查询,不处理 URL;没有覆盖其他单据的扫码预览 |
## 4. éœ€æ±‚范围
覆盖 8 ç±»æœ‰ç”Ÿäº§è¿½æº¯ç çš„仓储业务单据,两端对齐;供应商来料类单据不接入:
| ä¸šåŠ¡ | PC é¡µé¢ | ç§»åŠ¨ç«¯ | æ‰«ç å¸¦å‡ºå†…容 |
|------|---------|--------|--------------|
| äº§å“å…¥åº“ | `wls/productreceipt` | å¾…è¡¥ | ç‰©æ–™ã€æ‰¹æ¬¡ã€æ•°é‡ |
| é”€å”®å‡ºåº“ | `wls/productsales` | å·²æœ‰ï¼Œè¡¥é½ URL è§£æž | æ‹£è´§æ˜Žç»†ï¼ˆè¢‹ç å¯åˆ°è¢‹ï¼‰ |
| ç”Ÿäº§é¢†æ–™ | `wls/productissue` | å¾…è¡¥ | ç‰©æ–™ã€æ‰¹æ¬¡ã€æ•°é‡ |
| å…¶ä»–出库 | `wls/miscissue` | å¾…è¡¥ | ç‰©æ–™ã€æ‰¹æ¬¡ã€æ•°é‡ |
| ç§»åº“ / è°ƒæ‹¨ | `wls/transfer` | å¾…è¡¥ | ç‰©æ–™ã€æ‰¹æ¬¡ã€æ•°é‡ã€ç›®æ ‡åº“位 |
| ç›˜ç‚¹ä»»åŠ¡ | `wls/stocktaking/task` | å¾…è¡¥ | ç›˜ç‚¹å¯¹è±¡ã€è´¦é¢æ•°é‡ã€å½•入盘点数量 |
| é”€å”®é€€è´§ | `wls/returnsales` | å¾…è¡¥ | ç‰©æ–™ã€æ‰¹æ¬¡ã€æ•°é‡ |
| ç”Ÿäº§é€€æ–™ | `wls/returnissue` | å¾…è¡¥ | ç‰©æ–™ã€æ‰¹æ¬¡ã€æ•°é‡ |
不接入(供应商来料,批次由后端按物料 + ç”Ÿäº§æ—¥æœŸ + æ‰¹å·è‡ªåŠ¨ç”Ÿæˆï¼Œæ‰«ç å¾—åˆ°çš„ç”Ÿäº§è¿½æº¯ç ä¸Žæ¥æ–™ä¸åŒ¹é…ï¼‰ï¼šé‡‡è´­å…¥åº“ã€å§”å¤–å…¥åº“ã€å…¶ä»–å…¥åº“ã€é‡‡è´­é€€è´§ã€‚
质检(种子质检、质检记录)不在本方案内,移动端已单独实现。
## 5. ç»Ÿä¸€äº¤äº’流程
每个单据的明细区上方增加"扫码带出"区域,流程固定为四步:
1. **选择码类型**:自动识别(默认)/ è¢‹ç  / æ‰˜ç›˜ç  / æ‰¹æ¬¡ç ã€‚自动识别调用 `resolve-type`。
2. **扫码或输入**:摄像头扫码、扫码枪回车、手动输入三种方式等价;识别后先提取码值,再解析对象。
3. **核对确认**:展示核对卡片——码值、码类型、品种、批次、可操作数量、袋明细(托盘 / æ‰¹æ¬¡æ‰«ç æ—¶é€è¢‹å‹¾é€‰ï¼‰ã€ç›®æ ‡ä»“库库位(需要操作员选择的字段)。
4. **确认后写入**:点击"确认加入明细"才调用原单据的新增明细 / æ‰«ç æŽ¥å£ï¼›ä»»ä½•写操作都必须经过这一步,识别本身不入账。
已存在且可复用的交互基准:PC é”€å”®å‡ºåº“拣货(预览 â†’ å‹¾é€‰è¢‹ â†’ ç¡®è®¤åŠ å…¥æ‹£è´§ï¼‰ã€ç§»åŠ¨ç«¯æ‰«ç å…¥åº“ï¼ˆè¯†åˆ« â†’ æ ¸å¯¹ â†’ ç¡®è®¤å…¥åº“)。
## 6. æ˜Žç»†ç²’度规则(必须先确认)
这是本方案影响最大的约束:
- **销售出库明细**有 `bag_code_id` / `pallet_code_id`,可以精确到袋;
- **其余单据明细只有 `batch_id` / `batch_code`**,没有袋字段。
因此:
| æ‰«ç å¯¹è±¡ | é”€å”®å‡ºåº“ | å…¶ä»–单据 |
|----------|----------|----------|
| è¢‹ç  | ç²¾ç¡®åˆ°è¯¥è¢‹ï¼Œæ•°é‡ 1 | æŒ‰"物料 + æ‰¹æ¬¡"带出,数量 1,**不记录具体袋** |
| æ‰˜ç›˜ç  | æ•´æ‰˜ï¼Œé€è¢‹å‹¾é€‰ | æŒ‰æ‰˜ç›˜æ‰€å±žæ‰¹æ¬¡å¸¦å‡ºæ•´æ‰˜è¢‹æ•° |
| æ‰¹æ¬¡ç  | æ•´æ‰¹ï¼Œé€è¢‹å‹¾é€‰ | æŒ‰æ‰¹æ¬¡å¸¦å‡ºæ•´æ‰¹è¢‹æ•° |
如果业务要求"每一袋都能追溯到进入哪张入库单 / ç§»åº“单",需要给相关明细表增加袋字段并改造库存事务,属于独立的大改动,建议单独立项。
## 7. æŽ¥å£è®¾è®¡
### 7.1 å¤ç”¨ï¼ˆä¸æ–°å¢žï¼‰
| æ–¹æ³• | è·¯å¾„ | ç”¨é€” |
|------|------|------|
| GET | `/mes/pro/trace/resolve-type` | ç å€¼ â†’ bag / pallet / batch |
| GET | `/mes/pro/bag-code/get-by-code` | è¢‹ç è¯¦æƒ… |
| GET | `/mes/pro/pallet/get-by-code` | æ‰˜ç›˜è¯¦æƒ… |
| GET | `/mes/pro/batch/get-by-code` | æ‰¹æ¬¡è¯¦æƒ… |
| POST | `/mes/wm/product-sales/scan-preview` | é”€å”®å‡ºåº“拣货预览 |
| POST | `/mes/wm/product-sales/scan` | é”€å”®å‡ºåº“加入拣货 |
| POST | `/mes/wm/stocktaking-task/scan` | ç›˜ç‚¹æ‰«ç è®¡æ•° |
### 7.2 æ”¹é€ 
1. ä¸Šè¿°æ‰€æœ‰æŽ¥å£åœ¨æŽ¥æ”¶ç å€¼å‚数时,统一做"URL æå– code",兼容追溯二维码直传。
2. `resolve-type` å¢žåŠ å¯é€‰çš„ `bizType` å‚数,返回该码在当前业务下是否可用(可选,非必须)。
### 7.3 æ–°å¢žç»Ÿä¸€é¢„览接口
各单据的可用性校验不同(库存是否充足、批次是否作废、是否合格、是否已入库),前端无法自行判断。建议新增:
```
POST /mes/wm/trace/scan-preview
```
请求:
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| rawCode | String | æ˜¯ | æ‰«æåŽŸæ–‡ï¼Œå¯ä¸ºè¿½æº¯ç½‘å€æˆ–çº¯ç  |
| bizType | String | æ˜¯ | ä¸šåŠ¡ç±»åž‹ï¼šITEM_RECEIPT / PRODUCT_RECEIPT / PRODUCT_SALES / TRANSFER / STOCK_TAKING / RETURN_VENDOR ç­‰ |
| bizId | Long | å¦ | å•据 ID,用于校验批次是否在单据范围内 |
响应:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| codeType | String | bag / pallet / batch |
| code | String | è§„范化后的码值 |
| itemId / itemName | Long / String | å“ç§ |
| batchId / batchCode | Long / String | æ‰¹æ¬¡ |
| unitName | String | è®¡é‡å•位 |
| availableQuantity | BigDecimal | å½“前业务下可操作数量 |
| bags | Array | æ‰˜ç›˜ / æ‰¹æ¬¡åœºæ™¯çš„逐袋明细(袋码、库存记录 ID、库位) |
| blockReason | String | ä¸å¯æ“ä½œåŽŸå› ï¼Œä¸ºç©ºè¡¨ç¤ºå¯æ“ä½œ |
预览只读,不写任何数据。确认阶段的写操作仍调用各单据原有接口,保证库存校验和流水逻辑不变。
## 8. PC ç«¯æ‰«ç ç»„件要求
新增 PC é€šç”¨æ‰«ç è¾“入组件,供各单据复用:
- è¾“入框 + "打开摄像头"按钮 + "识别"按钮;扫码枪作为键盘输入,回车即识别;
- æ‘„像头复用 jsQR(项目已依赖),成功后自动停止相机,离页或切后台也停止;
- è¯†åˆ«ç»“果先走统一码值提取,再把规范化码值交给业务逻辑;
- æ ·å¼è‡ªåŒ…含,不依赖 `mobile.css` å’Œç§»åŠ¨ç«¯å›¾æ ‡ï¼›
- è¿žç»­æ‰«ç å¼€å…³ï¼šå¼€å¯æ—¶è¯†åˆ«æˆåŠŸåŽå¯ç»§ç»­æ‰«ä¸‹ä¸€è¢‹ï¼Œå…³é—­æ—¶è¯†åˆ«ä¸€æ¬¡å³åœï¼›
- ç›¸æœºä¸å¯ç”¨ã€æƒé™æ‹’绝、非 HTTPS æ—¶ç»™å‡ºä¸Žç§»åŠ¨ç«¯ä¸€è‡´çš„ä¸­æ–‡æç¤ºï¼Œå¹¶ä¿ç•™æ‰‹åŠ¨è¾“å…¥ã€‚
## 9. ç§»åŠ¨ç«¯è¡¥é½
1. `scanner.vue` çš„取码逻辑提升为公共模块,PC / ç§»åŠ¨å…±ç”¨åŒä¸€ä»½ `extractCode`。
2. æ–°å¢žç§»åŠ¨é¡µé¢ï¼šç›˜ç‚¹ä»»åŠ¡ã€ç§»åº“ã€é‡‡è´­é€€è´§ã€é”€å”®é€€è´§ã€ç”Ÿäº§é€€æ–™ï¼ˆæŒ‰ PC åŒä¸€äº¤äº’实现,只做现场高频字段)。
3. èœå•与路由沿用移动端现有的权限映射机制,不新增数据库菜单。
## 10. æƒé™
沿用现有权限,不新增权限码:
- ç æŸ¥è¯¢ï¼š`mes:pro-bag-code:query`、`mes:pro-pallet:query`、`mes:pro-batch:query`(识别码值必需);
- ä¸šåŠ¡æ“ä½œï¼šæ²¿ç”¨å„å•æ®çš„ `create` / `update` / `stock` / `scan` æƒé™ï¼ˆå¦‚ `mes:wm-product-sales:stock`、`mes:wm-stock-taking-task:update`);
- æ‰«ç å¸¦å‡ºå±žäºŽè¾…助录入,最终写操作仍按原业务权限校验。
## 11. å·²ç¡®è®¤å†³ç­–(2026-10-29)
| é—®é¢˜ | å†³ç­– |
|------|------|
| è¢‹çº§è¿½è¸ª | ä¸æ‰©å±•。除销售出库外,其余单据一律按"物料 + æ‰¹æ¬¡"带出,不记录具体袋 |
| å®žæ–½èŒƒå›´ | PC ç«¯ä¸Žç§»åŠ¨ç«¯ä¸€èµ·åšï¼Œè¦†ç›–ç¬¬ 4 èŠ‚ 8 ç±»ä¸šåŠ¡ï¼›æ¥æ–™ç±»å•æ®ä¸æŽ¥å…¥ |
| å†™å…¥æ–¹å¼ | æ‰«ç æ ¸å¯¹ç¡®è®¤åŽ**新增一行明细**,不覆盖正在编辑的行 |
| ç è§£æžåŸŸå | åªæå– `code` å‚数,不校验追溯网址域名,兼容旧标签的 HTTP åœ°å€ |
| èŒƒå›´æ ¡éªŒ | å…è®¸æ–°å¢žæ˜Žç»†ï¼Œä¸è¦æ±‚扫码对象已在单据已有行中 |
## 12. å®žæ–½æ‹†åˆ†
| å±‚ | å†…容 |
|----|------|
| åŽç«¯åŸºç¡€ | ç»Ÿä¸€ç å€¼æå–工具;`resolve-type` ä¸Žå„扫码接口兼容 URL å…¥å‚;新增 `POST /mes/wm/trace/scan-preview` ç»Ÿä¸€é¢„览 |
| PC å‰ç«¯åŸºç¡€ | å…¬å…± `extractCode`;通用扫码组件(摄像头 + æ‰«ç æžª + æ‰‹åŠ¨è¾“å…¥ï¼‰ |
| PC å•据接入 | ç¬¬ 4 èŠ‚ 12 ç±»å•据接入扫码带出,确认后新增明细行 |
| ç§»åŠ¨ç«¯åŸºç¡€ | å¤ç”¨å…¬å…± `extractCode` ä¸Žæ‰«ç ç»„ä»¶ |
| ç§»åŠ¨ç«¯æŽ¥å…¥ | è¡¥é½ç›˜ç‚¹ã€ç§»åº“、采购退货、销售退货、生产退料页面;现有页面接入公共解析 |
## 13. çŽ°åœºå¾…éªŒè¯
1. **追溯网址多环境**:现场旧标签可能是 HTTP åœ°å€æˆ–不同域名,解析只取 `code` å‚数。
2. **扫码枪结束符**:需现场确认设备是否在码值后追加回车 / æ¢è¡Œï¼Œç»„件按 `\r`、`\n` ä½œä¸ºç»“束符处理。
## 13. éªŒè¯æ–¹å¼
- åŽç«¯ï¼š`mvn compile -pl yudao-module-mes -am -q`;为码值提取、预览可用性、各单据扫码补充单元测试。
- PC ç«¯ï¼šæµè§ˆå™¨æ‘„像头实测扫追溯二维码、扫码枪输入、手动输入三种方式;核对确认后明细正确、库存流水正确。
- ç§»åŠ¨ç«¯ï¼šæ²¿ç”¨ `node scripts/mobile-ui-smoke.cjs` å›žå½’,并补充新页面用例。
- çŽ°åœºï¼šç”¨å®žé™…æ ‡ç­¾ï¼ˆè¿½æº¯äºŒç»´ç ï¼‰åœ¨ç›®æ ‡æ‰‹æœºã€PDA、PC ä¸Šå„扫一遍,确认相机权限、码值提取、结束符。
## 14. å®žæ–½è¿›åº¦ï¼ˆ2026-10-29)
### åŽç«¯
| å†…容 | çŠ¶æ€ | è¯´æ˜Ž |
|------|------|------|
| ç»Ÿä¸€ç å€¼æå– | å·²å®Œæˆ | `MesTraceCodeUtils.extractCode`,接入 `resolve-type`、扫码入库、销售出库拣货、盘点扫码 |
| å„扫码接口兼容 URL å…¥å‚ | å·²å®Œæˆ | è¯†åˆ«è¿½æº¯ç½‘址中的 `code` å‚æ•° |
### PC ç«¯
| ä¸šåŠ¡ | çŠ¶æ€ | è¯´æ˜Ž |
|------|------|------|
| ç”Ÿäº§é¢†æ–™ | å·²å®Œæˆ | æ‰«ç å¸¦å‡ºç‰©æ–™ä¸Žæ•°é‡ï¼Œæ–°å¢žé¢†æ–™è¡Œ |
| å…¶ä»–出库 | å·²å®Œæˆ | åŒ¹é…åº“存记录,带出库位/批次/数量 |
| åº“存移库 | å·²å®Œæˆ | åŒ¹é…åº“存记录,带出移出库位/批次/数量 |
| ç”Ÿäº§é€€æ–™ | å·²å®Œæˆ | åŒ¹é…çº¿è¾¹åº“(virtualFilter=only)库存 |
| é”€å”®é€€è´§ | å·²å®Œæˆ | å…³è”出库单时匹配可退行,否则按 WM æ‰¹æ¬¡åŒ¹é… |
| é”€å”®å‡ºåº“拣货 | å·²å®Œæˆ | è‡ªåŠ¨è¯†åˆ«è¢‹/托盘/批次 |
| ç›˜ç‚¹ä»»åŠ¡ | å·²å®Œæˆ | æ‰«ç æžª/摄像头输入 + æ•°é‡ï¼Œæäº¤ç›˜ç‚¹ç»“æžœ |
### ç§»åŠ¨ç«¯
| é¡µé¢ | çŠ¶æ€ | è¯´æ˜Ž |
|------|------|------|
| `productissue` ç”Ÿäº§é¢†æ–™ | å·²å®Œæˆ | è‰ç¨¿å• â†’ æ‰«ç  â†’ æ–°å¢žé¢†æ–™è¡Œ |
| `miscissue` å…¶ä»–出库 | å·²å®Œæˆ | è‰ç¨¿å• â†’ æ‰«ç åŒ¹é…åº“å­˜ â†’ æ–°å¢žå‡ºåº“行 |
| `transfer` åº“存移库 | å·²å®Œæˆ | è‰ç¨¿å• â†’ æ‰«ç åŒ¹é…åº“å­˜ â†’ æ–°å¢žç§»åº“行 |
| `stocktaking` ç›˜ç‚¹ä»»åŠ¡ | å·²å®Œæˆ | ç›˜ç‚¹ä¸­ä»»åŠ¡ï¼ˆstatus=2)→ æ‰«ç  + æ•°é‡ â†’ æäº¤ç›˜ç‚¹ |
| `returnsales` é”€å”®é€€è´§ | å·²å®Œæˆ | è‰ç¨¿å• â†’ å…³è”出库单匹配可退行,否则按 WM æ‰¹æ¬¡ |
| `returnissue` ç”Ÿäº§é€€æ–™ | å·²å®Œæˆ | è‰ç¨¿å• â†’ åŒ¹é…çº¿è¾¹åº“库存 â†’ æ–°å¢žé€€æ–™è¡Œ |
移动端菜单通过后端 `MobileMenuAdapter` ä»Ž PC èœå•映射生成,已在 `PAGES` å¢žåŠ  6 æ¡æ˜ å°„并同步单测;前端 `routes.ts`、`access.ts` å¢žåŠ å¯¹åº”è·¯ç”±ä¸ŽåŠŸèƒ½å…¥å£ï¼Œæœªæ–°å¢žæ•°æ®åº“èœå•ã€‚
采购入库、委外入库、其他入库、采购退货按第 11 èŠ‚å†³ç­–**不接入**扫码。
### å®žçŽ°è¦ç‚¹
- è¿½æº¯å¯¹è±¡ `itemId` ä¸Žåº“å­˜/批次同为 `mdm_item` ID ä½“系;但 WM æ‰¹æ¬¡ ID ä¸Žç”Ÿäº§æ‰¹æ¬¡ ID ä¸åŒï¼Œå†™æ˜Žç»†æ—¶æ‰¹æ¬¡ ID ä¸€å¾‹å–库存记录的 `batchId`,库存查询按 `batchCode` å­—符串匹配。
- æ˜Žç»†æ–°å¢žè¦æ±‚父单为草稿;盘点扫码要求任务状态为审批中(`APPROVING=2`)。
- æ‰«ç å¯¹è±¡æ‰€å±žæ‰¹æ¬¡/自身已作废时,前后端统一拦截,不允许写操作。
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/trace/MesProTraceResolveController.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.trace;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesTraceCodeUtils;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletService;
@@ -38,13 +39,14 @@
    @Parameter(name = "code", description = "编码(二维码内容)", required = true)
    @PreAuthorize("@ss.hasAnyPermissions('mes:pro-bag-code:query', 'mes:pro-pallet:query', 'mes:pro-batch:query')")
    public CommonResult<String> resolveType(@RequestParam("code") String code) {
        if (bagCodeService.getBagCodeByScanCode(code) != null) {
        String scanCode = MesTraceCodeUtils.extractCode(code);
        if (bagCodeService.getBagCodeByScanCode(scanCode) != null) {
            return success("bag");
        }
        if (palletService.getPalletByScanCode(code) != null) {
        if (palletService.getPalletByScanCode(scanCode) != null) {
            return success("pallet");
        }
        if (batchService.getBatchByScanCode(code) != null) {
        if (batchService.getBatchByScanCode(scanCode) != null) {
            return success("batch");
        }
        return success(null);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesTraceCodeUtils.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.mes.framework.qrcode;
import cn.hutool.core.util.StrUtil;
import org.springframework.web.util.UriComponentsBuilder;
/**
 * è¿½æº¯ç æå–工具。
 *
 * <p>现场标签印的是追溯二维码,内容为 {@code {追溯网址}?code={追溯码}};
 * æ‰«ç æžªä¹Ÿå¯èƒ½æŠŠæ•´æ®µç½‘址直接提交。所有接收码值的接口都应先经过本类规范化,
 * é¿å…æŠŠç½‘址当作码值查询。前端 {@code src/utils/traceCode.ts} ä¿æŒåŒä¸€å£å¾„。
 */
public final class MesTraceCodeUtils {
    private static final int MAX_CODE_LENGTH = 64;
    private MesTraceCodeUtils() {
    }
    /**
     * ä»Žæ‰«æåŽŸæ–‡ä¸­æå–è¿½æº¯ç ï¼šç½‘å€å– {@code code} å‚数,其余原样使用。
     */
    public static String extractCode(String raw) {
        String value = StrUtil.trim(raw);
        if (StrUtil.isEmpty(value)) {
            throw new IllegalArgumentException("请扫描或输入追溯码");
        }
        if (value.regionMatches(true, 0, "http://", 0, 7) || value.regionMatches(true, 0, "https://", 0, 8)) {
            String code;
            try {
                code = UriComponentsBuilder.fromUriString(value).build().getQueryParams().getFirst("code");
            } catch (Exception ex) {
                throw new IllegalArgumentException("追溯网址格式不正确");
            }
            if (StrUtil.isBlank(code)) {
                throw new IllegalArgumentException("该追溯网址不包含追溯码");
            }
            value = code.trim();
        }
        if (value.length() > MAX_CODE_LENGTH || value.chars().anyMatch(Character::isWhitespace)) {
            throw new IllegalArgumentException("追溯码格式不正确");
        }
        return value;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/scan/MesProScanEventServiceImpl.java
@@ -17,6 +17,7 @@
import cn.iocoder.yudao.module.mes.enums.MesProScanEventStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmStockStateEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesTraceCodeUtils;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
@@ -61,13 +62,15 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesProScanEventDO createInbound(MesProScanEventCreateReqVO req) {
        // è¿½æº¯äºŒç»´ç å†…容是「追溯网址?code=码值」,扫码枪也可能直传整段网址,先统一提取码值
        String scanCode = MesTraceCodeUtils.extractCode(req.getBagCode());
        String scanType = (req.getScanType() == null || req.getScanType().isBlank()) ? SCAN_TYPE_BAG : req.getScanType();
        if (!List.of(SCAN_TYPE_BAG, SCAN_TYPE_PALLET, SCAN_TYPE_BATCH).contains(scanType)) {
            throw new ServiceException(400, "不支持的扫码类型");
        }
        MesProScanEventDO existing = eventMapper.selectByEventIdForUpdate(req.getEventId());
        if (existing != null) {
            boolean contentMatch = req.getBagCode().equals(existing.getBagCode())
            boolean contentMatch = scanCode.equals(existing.getBagCode())
                    && Objects.equals(req.getDeviceCode(), existing.getDeviceCode())
                    && Objects.equals(req.getLineCode(), existing.getLineCode())
                    && Objects.equals(scanType, existing.getScanType())
@@ -82,7 +85,7 @@
            return existing;
        }
        MesProScanEventDO event = new MesProScanEventDO();
        event.setEventId(req.getEventId()).setBagCode(req.getBagCode()).setScanType(scanType)
        event.setEventId(req.getEventId()).setBagCode(scanCode).setScanType(scanType)
                .setQuantity(req.getQuantity()).setDeviceCode(req.getDeviceCode()).setLineCode(req.getLineCode())
                .setRemark(req.getRemark()).setLocationId(req.getLocationId()).setAreaId(req.getAreaId())
                .setStatus(MesProScanEventStatusEnum.FAILED).setReceivedTime(LocalDateTime.now());
@@ -96,7 +99,7 @@
        Long bagCodeId = null;
        String bagCode = null;
        if (SCAN_TYPE_PALLET.equals(scanType)) {
            MesProPalletDO pallet = palletMapper.selectByScanCodeForUpdate(req.getBagCode());
            MesProPalletDO pallet = palletMapper.selectByScanCodeForUpdate(scanCode);
            if (pallet == null) return failed(event, "MES_SCAN_PALLET_NOT_EXISTS", "托盘码不存在");
            if (MesProPalletStatusEnum.CANCELED.getStatus().equals(pallet.getStatus()))
                return failed(event, "MES_SCAN_PALLET_CANCELED", "托盘已作废,不可入库");
@@ -107,13 +110,13 @@
            targetId = pallet.getId();
            quantity = BigDecimal.valueOf(bagCodeMapper.selectPendingInboundCountByPalletId(pallet.getId()));
        } else if (SCAN_TYPE_BATCH.equals(scanType)) {
            batch = batchMapper.selectByScanCodeForUpdate(req.getBagCode());
            batch = batchMapper.selectByScanCodeForUpdate(scanCode);
            if (batch == null) return failed(event, "MES_SCAN_BATCH_NOT_EXISTS", "生产批次不存在");
            batchId = batch.getId();
            targetId = batch.getId();
            quantity = BigDecimal.valueOf(bagCodeMapper.selectPendingInboundCountByBatchId(batch.getId()));
        } else {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(req.getBagCode());
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(scanCode);
            if (bag == null) return failed(event, "MES_SCAN_BAG_NOT_EXISTS", "袋码不存在");
            if (MesProBagCodeStatusEnum.CANCELED.getStatus().equals(bag.getStatus()))
                return failed(event, "MES_SCAN_BAG_CANCELED", "袋码已作废,不可入库");
@@ -167,7 +170,7 @@
            return event;
        } catch (Exception ex) {
            log.error("[createInbound] åº“存事务处理失败,eventId={}, bagCode={}, batchId={}, itemId={}",
                    req.getEventId(), req.getBagCode(), batchId, batch.getItemId(), ex);
                    req.getEventId(), scanCode, batchId, batch.getItemId(), ex);
            throw ex;
        }
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -32,6 +32,7 @@
import cn.iocoder.yudao.module.mes.enums.wm.MesWmSalesNoticeStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmStockReserveBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesTraceCodeUtils;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
@@ -254,7 +255,7 @@
    /** åªè¯»è¯†åˆ«ï¼Œå¹¶è¿”回每袋的可选状态,不创建库存批次或拣货记录。 */
    @Override
    public MesWmProductSalesScanRespVO previewScan(String code, String scanType, Long salesId) {
        PickingScope scope = pickingScope(code, scanType, false);
        PickingScope scope = pickingScope(MesTraceCodeUtils.extractCode(code), scanType, false);
        if (salesId != null) {
            MesWmProductSalesDO sales = validateProductSalesExists(salesId);
            if (!Integer.valueOf(2).equals(sales.getStatus())) throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
@@ -284,7 +285,7 @@
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(reqVO.getSalesId());
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (!Integer.valueOf(2).equals(sales.getStatus())) throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
        PickingScope scope = pickingScope(reqVO.getScanContent(), reqVO.getScanType(), true);
        PickingScope scope = pickingScope(MesTraceCodeUtils.extractCode(reqVO.getScanContent()), reqVO.getScanType(), true);
        List<Long> ids = reqVO.getBagIds();
        if (ids == null && "BAG".equals(scope.type())) ids = List.of(scope.id());
        if (ids == null || ids.isEmpty() || new HashSet<>(ids).size() != ids.size())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/stocktaking/task/MesWmStockTakingTaskServiceImpl.java
@@ -23,6 +23,7 @@
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmStockTakingTaskStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesTraceCodeUtils;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
@@ -285,10 +286,12 @@
    /**
     * è§£æžè¿½æº¯ç å®šä½ä»“库批次:依次匹配批次号、袋码、托盘码,生产批次经桥接返回仓库批次
     */
    private MesWmBatchDO resolveWmBatchByScanCode(String scanCode) {
        if (StrUtil.isBlank(scanCode)) {
    private MesWmBatchDO resolveWmBatchByScanCode(String rawCode) {
        if (StrUtil.isBlank(rawCode)) {
            return null;
        }
        // è¿½æº¯äºŒç»´ç å†…容为「追溯网址?code=码值」,先统一提取码值再匹配
        String scanCode = MesTraceCodeUtils.extractCode(rawCode);
        // 1. ç›´æŽ¥å‘½ä¸­ä»“库批次号
        MesWmBatchDO wmBatch = mesWmBatchService.getBatchByCode(scanCode);
        if (wmBatch != null) {
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapter.java
@@ -8,15 +8,21 @@
/** Maps already-authorized, visible menus to implemented mobile pages. Does not grant permissions. */
public final class MobileMenuAdapter {
    private MobileMenuAdapter() { }
    private static final Map<String, String> PAGES = Map.of(
            "mes/pro/scan-in/index", "inbound",
            "mes/pro/bagCode/index", "bags",
            "mes/pro/pallet/index", "pallets",
            "mes/pro/batch/index", "batches",
            "wls/materialstock/index", "stock",
            "mes/qc/seedQuality/index", "quality",
            "mes/qc/seedQuality/record", "quality-records",
            "wls/productsales/index", "sales");
    private static final Map<String, String> PAGES = Map.ofEntries(
            Map.entry("mes/pro/scan-in/index", "inbound"),
            Map.entry("mes/pro/bagCode/index", "bags"),
            Map.entry("mes/pro/pallet/index", "pallets"),
            Map.entry("mes/pro/batch/index", "batches"),
            Map.entry("wls/materialstock/index", "stock"),
            Map.entry("mes/qc/seedQuality/index", "quality"),
            Map.entry("mes/qc/seedQuality/record", "quality-records"),
            Map.entry("wls/productsales/index", "sales"),
            Map.entry("wls/productissue/index", "productissue"),
            Map.entry("wls/miscissue/index", "miscissue"),
            Map.entry("wls/transfer/index", "transfer"),
            Map.entry("wls/stocktaking/task/index", "stocktaking"),
            Map.entry("wls/returnsales/index", "returnsales"),
            Map.entry("wls/returnissue/index", "returnissue"));
    public static List<MenuVO> adapt(List<MenuVO> menus) {
        List<MenuVO> result = new ArrayList<>();
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapterTest.java
@@ -23,6 +23,16 @@
    assertEquals(3,root.getChildren().size());
    assertTrue(MobileMenuAdapter.adapt(null).isEmpty());
  }
  @Test void mapsWarehouseOperationMenusToMobilePages() {
    var root=menu(1L,null,true);
    root.setChildren(List.of(
        menu(2L,"wls/productissue/index",true),menu(3L,"wls/miscissue/index",true),
        menu(4L,"wls/transfer/index",true),menu(5L,"wls/stocktaking/task/index",true),
        menu(6L,"wls/returnsales/index",true),menu(7L,"wls/returnissue/index",true)));
    assertEquals(List.of("/mobile/productissue","/mobile/miscissue","/mobile/transfer",
        "/mobile/stocktaking","/mobile/returnsales","/mobile/returnissue"),
        MobileMenuAdapter.adapt(List.of(root)).stream().map(MenuVO::getPath).toList());
  }
  private MenuVO menu(Long id,String component,boolean visible) {
    return MenuVO.builder().id(id).name("menu").component(component).visible(visible).build();
  }