云
19 小时以前 704265308b1227dbada5a36b2027b588c062ded2
feat(mes): 新增业务码支持并优化追溯查询功能

- 在批次、托盘、袋码表中新增 business_code 字段及唯一约束
- 实现内部扫码兼容业务码与历史追溯码的查询机制
- 添加补生成已有码业务码的功能接口
- 优化托盘绑定时品种信息的自动解析和合并逻辑
- 完善批次入库时有效袋码的进度计算机制
- 更新追溯响应VO增加入库时间等关键字段
- 重构批次预生成逻辑使用统一编码生成器分配码值
- 优化页面列表查询支持业务码模糊匹配搜索
已添加30个文件
已修改51个文件
2937 ■■■■■ 文件已修改
docs/一袋双码实施说明.md 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/托盘与生产批次品种绑定说明.md 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/移动端一期实施说明.md 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/移动端设计方案.md 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/移动端质检出库与自动完成实施说明.md 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/browser-startup-smoke.cjs 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/code-toolbar-smoke.cjs 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/mobile-quality-sales-smoke.cjs 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/mobile-ui-smoke.cjs 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/pc-sales-selection-smoke.cjs 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_bag_business_code.sql 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_batch_bag_code_init.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_pallet_batch_business_code.sql 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_pallet_item.sql 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagBusinessCodeGenerateReqVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodePageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeTraceRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/MesProBatchController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchPageReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchPreGenerateRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchRespVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchSaveReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchTraceRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/MesProPalletController.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletPageReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletPreGenerateRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletRespVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletTraceRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/MesProScanEventController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/vo/MesProScanEventPageReqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | 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/controller/admin/pro/vo/MesProBusinessCodeGenerateReqVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/MesQcSeedQualityController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityGroupSubmitReqVO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanReqVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanRespVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/detail/MesWmProductSalesDetailRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/bagcode/MesProBagCodeDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/batch/MesProBatchDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/pallet/MesProPalletDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/batch/MesProBatchMapper.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/pallet/MesProPalletMapper.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/scan/MesProScanEventMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstockbag/MesWmMaterialStockBagMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesDetailMapper.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilder.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/batch/MesProBatchService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/batch/MesProBatchServiceImpl.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletItemResolver.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletServiceImpl.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/scan/MesProScanEventServiceImpl.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesDetailServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/stocktaking/task/MesWmStockTakingTaskServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/MobileScanAccessTest.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/BagBusinessCodeTest.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/PalletBatchBusinessCodeTest.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/PalletItemBindingTest.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/scan/InboundCompletionSqlTest.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/scan/MobileScanRetryTest.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/scan/MobileScanValidityTest.java 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityGroupTest.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityScanTest.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/BagBusinessOutboundTest.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/SalesPickingSelectionTest.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/SalesPickingSqlTest.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapter.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapterTest.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/Ò»´üË«Âëʵʩ˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
# ä¸€è¢‹åŒç å®žæ–½è¯´æ˜Ž
## è¡Œä¸º
同一袋记录新增 `businessCode`(数据库 `business_code`),原 `code` ä¿ç•™ä¸ºè¿½æº¯ç ã€‚业务码与追溯码各自生成,均通过既有 MES / Redis å…¨å±€å”¯ä¸€ç¼–码生成器分配 16 ä½æ•°å­—,以字符串保存和传输。
生成数据包、生成原始袋码、破损替换均同时生成两种码。更换破损袋时发放新业务码,旧袋码仍按既有流程作废。业务二维码内容为存储的业务码值,导出图片为二维码加下方码值;追溯二维码继续使用既有 H5 æº¯æºç½‘址规则。
一袋一码管理列表新增业务码列及业务码筛选。Excel åŒæ—¶å¯¼å‡ºè¿½æº¯ç ã€ä¸šåŠ¡ç ï¼›å·¥å…·æ å¯åˆ†åˆ«å¯¼å‡ºè¿½æº¯äºŒç»´ç å’Œä¸šåŠ¡äºŒç»´ç ï¼ŒæŒ‰ç”Ÿæˆç»„ UUID æˆ–批次导出;行内可预览业务二维码。未补业务码的历史记录不能导出业务二维码,已作废记录排除于二维码批量导出。
## åŽ†å²æ•°æ®
勾选已有有效袋码,点击“补生成业务码”,单次最多 200 æ¡ã€‚接口沿用袋码生成权限 `mes:pro-bag-code:create`。服务按排序后的去重 ID åŠ è¡Œé”ï¼Œä»…ä¸ºæ— ä¸šåŠ¡ç çš„è®°å½•ç”Ÿæˆï¼›é‡å¤æ“ä½œä¸ä¿®æ”¹å·²æœ‰ç å€¼ï¼Œä¸æ”¹å˜åŽŸè¿½æº¯ç ã€ç»‘å®šå…³ç³»ã€åº“å­˜å’Œå°åˆ·çŠ¶æ€ã€‚å­˜åœ¨ç¼ºå¤±æˆ–ä½œåºŸè¢‹ç æ—¶æ•´æ¬¡æ‹’ç»ã€‚
## æ‰«ç å¤„理
管理后台与移动端按业务码或历史追溯码查找同一袋记录;入库请求保留实际扫描码值便于审计,库存流水仍以原追溯码及同一袋 ID ä¸ºè§„范标识,不将双码拆成两份库存。销售出库拣货按同一袋 ID åŽ»é‡ï¼Œå¹¶ä¿ç•™æ—¢æœ‰è´¨æ£€æ ¡éªŒï¼›ç›˜ç‚¹æ‰«ç å®šä½ä»“åº“æ‰¹æ¬¡åŒæ ·è¯†åˆ«ä¸šåŠ¡ç ã€‚
公众防伪 / æº¯æºä»¥åŠå…¬ä¼—扫码事件继续只使用追溯码;业务码不作为公众追溯入口。管理后台用业务码查到一袋信息后,追溯二维码预览会使用该袋原追溯码,避免生成无效的公众链接。
新增接口:
- `POST /mes/pro/bag-code/generate-business-code`,请求 `{ "ids": [1, 2] }`,返回新生成的数量。
- `GET /mes/pro/bag-code/export-business-qr?uuid=...`,或 `batchId=...`,返回 ZIP,沿用袋码导出权限。
## éƒ¨ç½²
1. åœ¨ç›®æ ‡ MySQL åº“执行 `sql/mysql/mes_pro_bag_business_code.sql` å’Œ `sql/mysql/mes_pro_pallet_batch_business_code.sql`,为袋码、托盘和生产批次新增可空的 VARCHAR(16) å­—段及唯一索引。脚本检查字段和索引是否存在,可重复执行,不自动回填或修改已有追溯码。
2. éƒ¨ç½² MES åŽç«¯åŠå‰ç«¯æž„建。新增 DO æŸ¥è¯¢ä¼šè¯»å–该字段,须先升级数据库再启动后端。
3. åœ¨æµ‹è¯•业务数据上验证生成、导出、扫描、入库与销售出库;已有标签通过勾选补生成业务码,再导出交付印刷。
2026-10-10 å·²æŒ‰ç”¨æˆ·è¦æ±‚对本机后端默认连接的 `localhost:3306/mom-thzy` æ‰§è¡Œä¸Šè¿°ä¸¤ä¸ªè„šæœ¬ï¼Œç¡®è®¤è¢‹ç ã€æ‰˜ç›˜ã€ç”Ÿäº§æ‰¹æ¬¡ä¸‰è¡¨å‡æœ‰ `business_code VARCHAR(16)` å¯ç©ºå­—段及 `uk_business_code` å”¯ä¸€ç´¢å¼•。袋码原有字段和索引通过脚本检查后保留,托盘和批次补齐缺失结构。没有执行真实库存写入或历史业务码回填;其他部署环境仍须分别执行升级脚本。新建库的初始化表结构已同步包含业务码字段与唯一索引。
## éªŒè¯
前端生产构建通过;480×800 æ¨¡æ‹Ÿæµè§ˆå™¨å›žå½’覆盖纯业务码识别、显式确认入库、超时重试和原有移动流程。后端相关 30 é¡¹æµ‹è¯•通过,覆盖双码生成、替换、补生成幂等 / ä½œåºŸæ ¡éªŒã€çº¯ç äºŒç»´ç è¯†è¯»ã€å…¬ä¼—与内部查询分离、规范库存码值、托盘与批次按实际袋数入库、销售拣货去重和质检限制,以及移动权限与扫码回归。
全项目类型检查仍有既有错误,本次一袋一码、业务二维码弹层、API ä¸Žç§»åŠ¨æŸ¥è¯¢æ”¹åŠ¨æœªè¢«æŠ¥å‘Šç±»åž‹é”™è¯¯ã€‚æ¨¡æ‹ŸæŽ¥å£å’Œå•å…ƒæµ‹è¯•ä¸æ›¿ä»£çŽ°åœºè®¾å¤‡ä¸ŽçœŸå®žåº“å­˜è”è°ƒã€‚
## æ‰˜ç›˜å’Œç”Ÿäº§æ‰¹æ¬¡æ‰©å±•
托盘和生产批次同样拥有独立的 `businessCode`。预生成时即分配并保存,托盘绑定批次、批次补录生产信息时保持业务码不变。直接生成托盘、新建生产批次、生成袋码时自动建立的托盘也生成业务码。
预生成统一使用编码生成器,不再执行旧的 `MAX(CAST(SUBSTRING(temp_code, 4) AS UNSIGNED))` åºå·æŸ¥è¯¢ã€‚该旧查询会将长数字临时码误读为 Integer åºå·å¹¶æº¢å‡ºï¼›ä¿®å¤æ— éœ€æ¸…理历史标签或改写已有临时码,更新后端生效。
托盘管理按选中记录补生成业务码、导出纯码二维码;生产批次在行内更多操作中补生成、预览和导出整套业务二维码(批次自身及其下有效托盘)。历史补生成最多 200 æ¡ï¼Œä»…补缺失记录,作废或缺失记录整次拒绝,重复操作保持已有码值。
内部查询支持业务码、正式码和临时码。整托、整批入库使用原有记录的实际袋数和原生产批次关联,扫描业务码不会创建新的库存身份。销售拣货的既有整托路径也识别托盘业务码;本次没有新增整批销售出库流程。公众溯源仍使用原追溯入口。
新增接口:
- `POST /mes/pro/pallet/generate-business-code`、`POST /mes/pro/batch/generate-business-code`,请求 `{ "ids": [1, 2] }`。
- `POST /mes/pro/pallet/export-business-qr-by-ids`,请求 `{ "ids": [1, 2] }`。
- `GET /mes/pro/batch/export-business-qr?batchId=...`,导出批次与有效托盘业务码 ZIP。
## ç®¡ç†é¡µé¢æ ·å¼
沿用现有系统的蓝白配色、按钮、表格及弹层组件。袋码、托盘、批次工具栏保持标题单行,常用操作直接展示,导出集中到“导出”菜单,补生成等低频操作放入“更多操作”或行内菜单,避免工具栏堆满按钮。桌面回归检查 1920 å’Œ 1280 å®½åº¦çš„三页布局与业务二维码预览。
docs/ÍÐÅÌÓëÉú²úÅú´ÎÆ·ÖÖ°ó¶¨ËµÃ÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
# æ‰˜ç›˜ä¸Žç”Ÿäº§æ‰¹æ¬¡å“ç§ç»‘定
批次新增、补录、编辑以及托盘直接绑定批次均校验:选中托盘只能属于相同品种,且与批次已有品种、有效袋码和已绑定托盘一致。品种不同或缺失时拒绝整个操作;品种由托盘带入,不能通过修改请求参数更换已有批次的品种。
托盘新增 `item_id`,袋码数据包自动生成托盘时立即保存品种编号;首次装袋时确定品种,后续只能装入同品种袋码。历史托盘可以从有效袋码推断品种,混品种或缺失品种不能绑定。作废袋码不参与品种推断。
批次表单显示只读品种名称及编码,选择首个托盘后自动带入,并禁用其他品种及缺少品种的托盘。编辑已有品种批次时,选项按该品种限制。托盘和批次列表及 Excel åŒæ—¶å±•示品种名称和编码,未绑定批次的自动托盘也能展示品种。
袋码录入批次时的托盘级联绑定同样执行品种校验,避免绕过批次页面限制。绑定完成后同步更新批次完整性摘要。
## æ•°æ®åº“
升级脚本:`sql/mysql/mes_pro_pallet_item.sql`。执行前核对目标库;新增托盘品种字段,只回填由有效袋码唯一确定、且不与已有批次品种冲突的记录。不会更改已有码值或库存。
2026-10-10 å·²é€šè¿‡ MySQL MCP åœ¨æœ¬åœ° `mom-thzy` å®Œæˆå‡çº§ï¼Œå›žå¡« 2 ä¸ªæ‰˜ç›˜ã€‚原批次 1 çš„æ‘˜è¦è¦†ç›–品种,直接修改历史摘要被自动审批拒绝;已撤回本次该批次的品种回填,保留原审计记录。历史批次通过正常编辑保存即可由有效袋码和托盘推断品种并刷新摘要,迁移脚本仅回填尚无完整性摘要的批次,避免正常升级造成篡改误报。
## éªŒè¯
相关测试覆盖自动托盘保存品种、同品种带入、混品种拒绝、已有批次品种限制、有效袋码与托盘冲突、缺失品种拒绝、作废袋码忽略、请求伪造品种拒绝,以及业务码和入库回归。浏览器测试使用模拟接口,检查批次表单品种带入和选项禁用,不执行真实业务绑定或库存写入。
代码需更新后端并部署前端构建后生效;数据库升级已执行,但本次未重启用户的后端进程。
docs/ÒÆ¶¯¶ËÒ»ÆÚʵʩ˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
# ç§»åŠ¨ç«¯ä¸€æœŸå®žæ–½è¯´æ˜Ž
实施日期:2026-10-10。依据《移动端设计方案.md》第 9 èŠ‚ç¬¬ä¸€é˜¶æ®µï¼ŒåŸºäºŽçŽ°æœ‰ `mom-pro2-before` é¡¹ç›®å¢žåŠ ç§»åŠ¨é¡µé¢ï¼Œå¤ç”¨å½“å‰åŽç«¯ã€è´¦å·å’Œè§’è‰²æƒé™ã€‚ç¬¬äºŒã€ä¸‰é˜¶æ®µä¸åœ¨æœ¬æ¬¡äº¤ä»˜èŒƒå›´ã€‚
## ä½¿ç”¨æ–¹å¼
进入原系统登录地址,选择“移动端”后登录,进入 `/mobile/home`。首次按 CSS è§†å£å®½åº¦åˆå§‹åŒ–:小于 768px é»˜è®¤ç§»åŠ¨ç«¯ï¼Œå¦åˆ™é»˜è®¤ PC ç«¯ï¼›å¯ä»¥æ‰‹åŠ¨ä¿®æ”¹ã€‚é€‰æ‹©åœ¨å½“å‰æ ‡ç­¾é¡µçš„ sessionStorage ä¿å­˜ï¼Œåˆ·æ–°ã€æ—‹è½¬å’Œçª—口缩放不会覆盖;显式退出清理选择及作业缓存,登录过期重新认证保留待确认操作。登录方式中的短信 mobile ä¸Žç•Œé¢ terminalType åˆ†å¼€ã€‚
移动布局以 480×800 CSS è§†å£ä¸ºåŸºå‡†ï¼Œæœ€å¤§å®½åº¦ 480px,支持内容滚动、安全区和软键盘。切换 PC ç«¯é‡æ–°åŠ è½½å¯¹åº”æƒé™èœå•ã€‚PC é¡µç»§ç»­ä½¿ç”¨çŽ°æœ‰å¸ƒå±€ã€‚
## å·²å®žçŽ°åŠŸèƒ½
| é¡µé¢ | åŠŸèƒ½ |
|---|---|
| å·¥ä½œå° / ä½œä¸š | å·²æŽˆæƒå…¥å£ã€ä¸Šæ¬¡æäº¤çš„位置、最近本人记录、无权限反馈 |
| æ‰«ç å…¥åº“ | ç”Ÿäº§æš‚存仓库区库位选择,袋码 / æ‰˜ç›˜ç  / æ‰¹æ¬¡ç è¯†åˆ«ï¼Œæ ¸å¯¹åŽç¡®è®¤ï¼Œç»“果展示 |
| è¢‹ç æŸ¥è¯¢ | æ‰«ç å’Œåˆ—表查询、完整详情、目标托盘与待绑定袋码采集、重复采集拒绝、确认绑定 |
| æ‰˜ç›˜æŸ¥è¯¢ | æ‰«ç å’Œåˆ—表查询、完整详情、绑定目标批次 |
| æ‰¹æ¬¡æŸ¥è¯¢ | æ‰«ç ã€åˆ—表和生产信息详情 |
| åº“存查询 | å“ç§ã€æ‰¹æ¬¡ã€å¯æœç´¢çš„仓库 / åº“区 / åº“位筛选,库存数量与单位明细,返回保留查询上下文 |
| ä½œä¸šè®°å½• | æœåŠ¡ç«¯é™å®šæœ¬äººï¼ŒæŒ‰æ”¶åˆ°æ—¶é—´åŠç»“æžœç­›é€‰ã€åˆ†é¡µã€é”™è¯¯å’Œæ“ä½œå·æ˜Žç»† |
| æˆ‘çš„ | ä½¿ç”¨è¯´æ˜Žã€åˆ‡æ¢ PC、退出 |
摄像头使用 getUserMedia + æŒ‰éœ€åŠ è½½ jsQR;用户触发授权,识别后暂停,离页或切后台停止相机。无法使用相机时可手动输入或使用键盘模拟扫码设备。溯源链接只提取 code å‚数,不打开链接。识别不会自动入库或绑定。
袋码数量允许编辑;整托 / æ•´æ‰¹æ•°é‡æŒ‰æ—¢æœ‰æœåŠ¡ç«¯è§„åˆ™è®¡ç®—ï¼Œå¹¶è®°å…¥äº‹ä»¶ã€‚ç¬¬ä¸€ç‰ˆä¸ºå•æ¬¡ç¡®è®¤ï¼Œä¸è‡ªåŠ¨è¿žç»­æäº¤ã€‚ç»‘å®šæŽ¥å£æ²¿ç”¨å·²æœ‰äº‹åŠ¡æŽ¥å£ï¼Œè¿”å›žæ•´ä½“æˆåŠŸç»“æžœï¼›æ²¡æœ‰è™šæž„é€æ¡æœåŠ¡ç«¯å›žæ‰§ã€‚ç»“æžœä¸ç¡®å®šæ—¶ä¿å­˜åŽŸæ“ä½œå¿«ç…§ï¼Œé€šè¿‡å¯¹è±¡å…³ç³»æ ¸å®žã€‚
## æƒé™ä¸ŽåŽç«¯æ”¹åЍ
`GET /system/auth/get-permission-info?terminalType=mobile` å…ˆèµ°çŽ°æœ‰ç”¨æˆ·ã€è§’è‰²ã€ç§Ÿæˆ·æŽˆæƒé€»è¾‘ï¼Œå†å°†å¯è§èœå•åŠå¯è§çˆ¶ç›®å½•ä¸‹çš„å·²é€‚é…ç»„ä»¶æ˜ å°„ä¸ºç§»åŠ¨èœå•ã€‚çœç•¥å‚æ•°æˆ–ä¼  pc ä¿æŒåŽŸèœå•ã€‚ç«¯ç±»åž‹ä¸ä¼šå¢žåŠ æŽ¥å£æƒé™ã€‚
首期映射保存在 `MobileMenuAdapter.java`,无需新增菜单或修改数据库:
- mes/pro/scan-in/index â†’ /mobile/inbound
- mes/pro/bagCode/index â†’ /mobile/bags
- mes/pro/pallet/index â†’ /mobile/pallets
- mes/pro/batch/index â†’ /mobile/batches
- wls/materialstock/index â†’ /mobile/stock
前端入口同时检查返回菜单与对应权限码,直接输入 URL ä¹Ÿç»è¿‡å®ˆå«ã€‚入库、查询、绑定继续由原接口检查权限。“记录”需要扫码记录 query æƒé™åŠæ‰«ç èœå•。
新增 `GET /mes/pro/scan-in/my-page` å’Œ `GET /mes/pro/scan-in/my-event`。本人分页覆盖客户端传入的 creator,单条核实仅返回本人事件;my-event å¯ç”±å…¥åº“权限访问,便于没有记录查询权限的操作员核实提交结果。码类型解析允许袋码、托盘、批次查询权限中的任一项,具体详情仍按码类型权限检查。
入库 eventId å†²çªä¸å†æ”¹å†™æ—§äº‹ä»¶ã€‚相同操作人、对象、类型、位置与有效数量的重试返回原结果;内容或操作人不同则拒绝。袋码数量参与比较;整托 / æ•´æ‰¹æ•°é‡ç”±æœåŠ¡å™¨å†³å®šï¼Œé‡è¯•ä¸ä»¥å®¢æˆ·ç«¯å ä½æ•°é‡æ”¹å˜åŽŸè®°å½•ã€‚
## å¼±ç½‘与缓存
入库提交前保存完整请求及稳定 eventId。超时显示“结果待确认”,刷新或重新登录后可核实本人事件;重试沿用原请求,不产生新操作号。绑定超时保存目标与对象 ID,核实已绑定关系,结果混合时保持待确认状态并提示人工核对。没有离线自动写入。
待确认操作缓存按当前用户及页面区分,仅保存在当前标签页。袋码 / æ‰˜ç›˜ / æ‰¹æ¬¡æŸ¥è¯¢å’Œåº“存页面在当前移动布局内保留筛选、已加载列表、详情与滚动位置;刷新浏览器会重新获取列表,退出登录会销毁页面缓存。加载更多沿用已提交筛选,失败不跳页;修改条件后点击查询才应用新条件。显式退出会清理;退出前有未核实业务应先完成核实。库位在重新选择时检查冻结及启用状态,入库位置在确认页再次展示。
## éªŒè¯ä¸Žå¯åЍ
浏览器回归脚本:`node scripts/mobile-ui-smoke.cjs`。默认测试地址 http://127.0.0.1:5173,可用 MOBILE_TEST_URL ä¿®æ”¹ï¼›éœ€å·²å®‰è£… Edge å’Œ scripts å†…çš„ Playwright。脚本拦截全部业务 API,使用独立模拟账号,不连接真实库存。覆盖 480×800 ç™»å½•默认、手动选端保持、扫码仅采集、禁用库位、相机不可用后输入、入库超时刷新恢复、原请求重试、重复袋码、确认绑定、本人记录、库存明细、无权限路由、菜单加载失败恢复、PC èœå•重载、退出清理、PC é»˜è®¤ã€æœç´¢é¢æ¿ç©ºç»“果与焦点循环、关闭滚动恢复、列表返回状态与滚动位置保留、分页网络失败重试及未提交筛选隔离。
前端生产构建通过。全量 vue-tsc ä»æœ‰é¡¹ç›®å·²æœ‰ä¸šåŠ¡é¡µé¢ / å…¬å…±ç±»åž‹æŠ¥é”™ï¼Œæ–°å¢ž mobile é¡µé¢åŠæœ¬æ¬¡è®¤è¯æ”¹åŠ¨æ²¡æœ‰è¢«æŠ¥å‘Šé”™è¯¯ï¼›ä¸èƒ½å°†æ­¤æ¬¡æž„å»ºé€šè¿‡ç­‰åŒäºŽå…¨é¡¹ç›®ç±»åž‹æ£€æŸ¥é€šè¿‡ã€‚
后端验证命令:
```powershell
mvn -o -pl yudao-module-mes -am '-Dtest=MobileMenuAdapterTest,MobileScanAccessTest,MobileScanRetryTest,MobileScanValidityTest' '-Dsurefire.failIfNoSpecifiedTests=false' test
```
13 é¡¹æµ‹è¯•全部通过,覆盖移动菜单隐藏父目录、PC èœå•原对象保留、伪造 creator、跨用户事件读取、重复请求、内容冲突、跨用户重试、袋码数量冲突及整托实际数量重试、作废袋码 / æ‰˜ç›˜ / æ‰¹æ¬¡æ‹’绝入库与非法扫码类型拒绝。
开发预览:在 mom-pro2-before æ‰§è¡Œ `pnpm dev --host 127.0.0.1`,以实际控制台地址为准。交付构建在该项目 dist å’Œ dist.zip;沿用原部署方式,同时部署后端 system / mes æ”¹åŠ¨ã€‚ç”Ÿäº§ build ä¿ç•™æ—¢æœ‰å…¬å¸çŽ¯å¢ƒå’Œ API é…ç½®ï¼Œä¸Šçº¿å‰æŒ‰ç›®æ ‡çŽ¯å¢ƒç¡®è®¤ã€‚
## çŽ°åœºè”è°ƒé¡¹ç›®
当前完成代码、构建、模拟浏览器及单元测试验证,尚未部署生产,也没有执行实际库存写入。现场需用目标手机 / PDA æ ¸æŸ¥ HTTPS ç›¸æœºã€å®žç‰©äºŒç»´ç å’Œæ‰«ç æžªç»“束字符,再用测试业务数据核对库存、单位、整托 / æ•´æ‰¹å…¥åº“及绑定事务。
菜单依赖现有数据库角色配置及父目录可见性。具有入库权限的用户还需对应码类型查询权限;隐藏父目录不会被移动端自动开放。既有仓库接口与数据权限继续生效,本次没有新增岗位或扩大仓库权限。
实现采用隔离的 CSS、原生 dialog åº•部搜索面板与表单控件,未引入 Vant。库区库位支持名称 / ç¼–码搜索、级联清空、加载反馈;面板关闭、离页时恢复焦点和页面滚动。可编辑移动菜单端配置、岗位布局、出库盘点报工属于后续增强。公众防伪 / æº¯æºé¡µç‹¬ç«‹ï¼Œæœ¬æ¬¡ç§»åŠ¨å…¥å£ä¸æ›¿ä»£å…¬ä¼—æŸ¥è¯¢ã€‚
## æœ¬è½®è¡¥å……(2026-10-10)
补齐可搜索的底部位置选择面板、查询返回上下文、分页筛选隔离、相机随缓存页面离开停止、浏览器缩放与安全区支持。作废对象可以继续查询,但前端阻止入库和绑定采集,后端对袋码、托盘及所属生产批次作废状态再次验证,并记录失败事件而不创建库存流水。PC å¸ƒå±€ç»§ç»­æ²¿ç”¨åŽŸå®žçŽ°ã€‚
## å·¥ä¸šç»ˆç«¯å¯åŠ¨æŽ’æŸ¥ï¼ˆ2026-10-10)
现场照片确认 DS-MDT302 è¿è¡Œ Android 10 / Chrome 77.0.3865.92,在 http://192.168.0.10:5666/ åœç•™äºŽå¯åŠ¨ç”»é¢ï¼Œæ™®é€šæ‰‹æœºå¯æ­£å¸¸è®¿é—®ã€‚å·²ç¡®è®¤å½“å‰ Vite å¼€å‘客户端包含可选链 / ç©ºå€¼åˆå¹¶è¯­æ³•,Chrome 77 æ— æ³•解析;此外 Tailwind 4 çš„官方 Chrome åŸºçº¿ä¸º 111。降低生产 build.target ä¸èƒ½ä½¿å½“前开发服务及全部样式兼容 Chrome 77。
优先将设备 Chrome æ›´æ–°åˆ°æœ€æ–°å¯å®‰è£…的稳定版(样式最低要求 Chrome 111,但此版本号不代表开发服务全部依赖的兼容保证)。Android 10 ç¬¦åˆ Chrome å½“前安装要求;企业设备的安装权限仍需现场确认。如果设备必须保留 Chrome 77,需要另行处理开发入口、依赖运行时 API、样式降级及实机回归,不能宣称现有版本已兼容。
新增独立 `/browser-check.html` æ£€æµ‹é¡µï¼Œä½¿ç”¨ ES5 è„šæœ¬æ˜¾ç¤º UA、模块导入、可选链、Proxy、存储、弹层等基础能力。新增应用启动失败及 30 ç§’未完成初始化提示,显示错误与检测入口,初始化最终成功会清除提示。主应用导入/语法失败时提示脚本仍能独立工作;诊断信息仅展示本机,不上传。缓存标签排序改为在 filter è¿”回的新数组上 sort,移除该处 toSorted API ä¾èµ–。
构建通过;启动检测回归命令为 `node scripts/browser-startup-smoke.cjs`。此检测不等同于 Chrome 77 æ•´ä¸ªå¹³å°å®žæœºå…¼å®¹éªŒè¯ã€‚源码修改需由实际提供 5666 æœåŠ¡çš„å‰ç«¯å®žä¾‹åŠ è½½ï¼Œå°šæœªæ“ä½œè®¾å¤‡æˆ–æ›¿æ¢çŽ°åœºæœåŠ¡ã€‚
依据:[Vite å¼€å‘浏览器要求](https://vite.dev/guide/)、[V8 å¯é€‰é“¾æ”¯æŒç‰ˆæœ¬](https://v8.dev/features/optional-chaining)、[Tailwind æ ·å¼å…¼å®¹è¦æ±‚](https://tailwindcss.com/docs/compatibility)、[Chrome Android å®‰è£…要求](https://support.google.com/chrome/answer/95346?co=GENIE.Platform%3DAndroid)。
docs/ÒÆ¶¯¶ËÉè¼Æ·½°¸.md
@@ -1,6 +1,6 @@
# MOM ç§»åŠ¨ç«¯è®¾è®¡æ–¹æ¡ˆ
版本:优化稿 V3(ui-ux-pro-max),2026-10-09。用途:功能与交互评审,尚未实施。
版本:优化稿 V3(ui-ux-pro-max),2026-10-09。2026-10-10 å·²æŒ‰ç¬¬ 9 èŠ‚å®žæ–½ç¬¬ä¸€é˜¶æ®µï¼Œå¹¶æ‰©å±•ç§å­è´¨æ£€ã€è´¨æ£€è®°å½•å’Œé”€å”®å‡ºåº“ï¼›äº¤ä»˜æƒ…å†µè§ã€Šç§»åŠ¨ç«¯ä¸€æœŸå®žæ–½è¯´æ˜Ž.md》和《移动端质检出库与自动完成实施说明.md》。其余第二、三阶段能力仍为规划。
本版保留浏览器访问、480 Ã— 800 è§†å£ã€ç™»å½•时选择移动端 / PC ç«¯ä»¥åŠæ•°æ®åº“权限映射。设计方向为清晰、紧凑的现场作业界面:一页围绕一个主要任务,始终显示作业对象与位置,操作结果可以回看。第 11~12 èŠ‚ä¸ºæœ¬æ¬¡è¡¥å……çš„è§†è§‰ç³»ç»Ÿã€é¡µé¢è§„æ ¼å’Œè¯„å®¡æ ‡å‡†ã€‚
@@ -26,7 +26,9 @@
| æ‰¹æ¬¡æŸ¥è¯¢ | æ‰¹æ¬¡ç”Ÿäº§ç®¡ç† / 602612 | `mes/pro/batch/index` | é¦–期查询 |
| åº“存查询 | åº“存现有量 / 5786 | `wls/materialstock/index` | é¦–期查询 |
| å…¥åº“任务 | äº§å“å…¥åº“ / 5819 | `wls/productreceipt/index` | ç¬¬äºŒé˜¶æ®µ |
| å‡ºåº“任务 | é”€å”®å‡ºåº“ / 5970 | `wls/productsales/index` | ç¬¬äºŒé˜¶æ®µ |
| é”€å”®å‡ºåº“ | é”€å”®å‡ºåº“ / 5970 | `wls/productsales/index` | å·²é€‚配查询、扫码拣货、运单、执行出库 |
| ç§å­è´¨æ£€ | ç§å­è´¨æ£€ / 1065217 | `mes/qc/seedQuality/index` | å·²é€‚配袋码、托盘码、生产批次码及四项指标提交 |
| è´¨æ£€è®°å½• | è´¨æ£€è®°å½• / 1065224 | `mes/qc/seedQuality/record` | å·²é€‚配袋码、批次、结果筛选 |
| å‘货任务 | å‘货通知 / 5867 | `wls/salesnotice/index` | ç¬¬äºŒé˜¶æ®µ |
| ç›˜ç‚¹ä»»åŠ¡ | ç›˜ç‚¹ä»»åŠ¡ / 5957 | `wls/stocktaking/task/index` | ç¬¬äºŒé˜¶æ®µ |
| åº“存流水 | åº“存事务流水 / 602476 | `wls/transaction/index` | é¦–期简版查询 |
@@ -229,7 +231,7 @@
移动端候选入口 âˆ© å½“前用户授权功能 âˆ© ç§Ÿæˆ·å¼€æ”¾èŒƒå›´ï¼Œæž„成实际菜单。每个按钮再检查对应操作权限;所有接口继续做服务端校验。隐藏目录的移动开放策略需要明确配置,不能因手机端入口重组而绕过权限。
端选择负责确定“展示哪套功能界面”,账号权限负责确定“允许访问哪些功能”。建议后端权限获取接口支持独立字段 `terminalType=pc|mobile`,返回所选端适用的已授权菜单;这是待开发的接口方案,现有接口尚未支持该参数。端类型不能作为新增权限的依据。
端选择负责确定“展示哪套功能界面”,账号权限负责确定“允许访问哪些功能”。后端权限获取接口已支持独立字段 `terminalType=pc|mobile`,返回所选端适用的已授权菜单,省略时保持 PC ç«¯è¡Œä¸ºã€‚端类型不能作为新增权限的依据。
PC ç«¯ä¿ç•™å½“前菜单组件映射。移动端建立对应的移动组件映射和功能清单,已经适配且有权限的功能才进入菜单。菜单端配置建议支持 PC、移动、双端,配置层级和迁移方式在开发时确认。父目录、按钮权限和租户范围需要一起处理,不能只根据菜单路径名称过滤。
@@ -241,7 +243,7 @@
### è®¿é—®å’Œç™»å½•
用户通过 HTTPS åœ°å€è¿›å…¥ç»Ÿä¸€ç™»å½•页,选择移动端或 PC ç«¯ï¼Œä½¿ç”¨çŽ°æœ‰ç³»ç»Ÿè´¦å·ç™»å½•ï¼Œå¤ç”¨ç™»å½•æ€åˆ·æ–°ã€é€€å‡ºå’Œæƒé™èŽ·å–æœºåˆ¶ã€‚å»ºè®®ç§»åŠ¨ä¸šåŠ¡å…¥å£è·¯å¾„ä¸º `/mobile/`,桌面业务和公众溯源页保留各自入口;这是规划路径,当前尚未创建。
用户通过 HTTPS åœ°å€è¿›å…¥ç»Ÿä¸€ç™»å½•页,选择移动端或 PC ç«¯ï¼Œä½¿ç”¨çŽ°æœ‰ç³»ç»Ÿè´¦å·ç™»å½•ï¼Œå¤ç”¨ç™»å½•æ€åˆ·æ–°ã€é€€å‡ºå’Œæƒé™èŽ·å–æœºåˆ¶ã€‚ç§»åŠ¨ä¸šåŠ¡å…¥å£è·¯å¾„å·²å®žçŽ°ä¸º `/mobile/`,桌面业务和公众溯源页保留各自入口。
登录页根据视口宽度默认选择端,用户手动选择具有优先权。所选端决定业务布局和菜单,后续宽度变化只做布局适配。较窄的桌面浏览器也能用于预览,但现场验收必须用目标设备完成。
@@ -402,11 +404,11 @@
| å†…容边距与分组间距 | 96px | é¦–尾及区域间留白 |
| å½“前作业上下文 | 64px | å²—位、位置、切换入口 |
| ä¸»æ“ä½œåŒºåŸŸ | 96px | æ‰«ç å…¥åº“ 52px,扫码查询次要入口 |
| å¸¸ç”¨å…¥å£ | 160px | ä¸¤è¡Œä¸¤åˆ—,共四项 |
| å¸¸ç”¨å…¥å£ | æŒ‰æƒé™è‡ªç„¶å¢žé•¿ | ä¸¤åˆ—排列全部已授权入口,包含质检与出库,内容超出时滚动 |
| æœ€è¿‘记录预览 | 220px | æ ‡é¢˜å’Œä¸¤æ¡è®°å½•,提供“查看全部” |
| å¼¹æ€§ä½™é‡ | 44px | é•¿ä½ç½®åç§°ã€æç¤ºæˆ–内容变化 |
总计 680px。待办任务接通后可替换记录预览,不在首屏叠加新的统计区。岗位没有四个可用入口时,按实际数量排列,不用无效功能补齐。
原四项入口时总计约 680px;扩展入口后按实际权限自然增长并滚动。待办任务接通后可替换记录预览,不叠加新的统计区,不用无效功能补齐。
扫码页采用 56px æ ‡é¢˜ã€64px ä¸Šä¸‹æ–‡ã€600px ä¸­é—´åŒºåŸŸå’Œ80px åº•部操作。中间区域容纳约 320px å–景区、操作说明、识别状态和手动输入入口;文字放在明亮背景中,避免直接覆盖复杂相机画面。相机取景与识别性能须在真实设备验证。
docs/ÒÆ¶¯¶ËÖʼì³ö¿âÓë×Ô¶¯Íê³Éʵʩ˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
# ç§»åŠ¨ç«¯è´¨æ£€ã€é”€å”®å‡ºåº“ä¸Žç”Ÿäº§å…¥åº“è‡ªåŠ¨å®Œæˆ
实施日期:2026-10-10。基于现有前端与后端扩展,沿用移动端蓝白样式、480×800 å¸ƒå±€å’Œè´¦å·æƒé™ã€‚
## åŠŸèƒ½ä¸Žå…¥å£
| å…¥å£ | åœ°å€ | åŠŸèƒ½ |
|---|---|---|
| ç§å­è´¨æ£€ | `/mobile/quality` | é€‰æ‹©è¢‹ç ã€æ‰˜ç›˜ç æˆ–生产批次码,核对待检袋数,录入四项指标及上下限,选择合格库,确认质检 |
| è´¨æ£€è®°å½• | `/mobile/quality-records` | æŒ‰ä¸šåŠ¡ç æˆ–è¿½æº¯ç ã€æ‰¹æ¬¡ã€ç»“æžœæŸ¥è¯¢ï¼ŒæŸ¥çœ‹æ£€éªŒæ—¶é—´å’ŒåŽŸå› ï¼Œåˆ†é¡µåŠ è½½ |
| é”€å”®å‡ºåº“ | `/mobile/sales` | æŸ¥è¯¢çŽ°æœ‰å‡ºåº“å•ï¼ŒæŸ¥çœ‹å“ç§åŠå·²æ‹£æ•°é‡ï¼Œæ ¸å¯¹è¢‹ç æˆ–æ•´æ‰˜ï¼Œå®Œæˆæ‹£è´§ã€å¡«å†™è¿å•ã€æ‰§è¡Œå‡ºåº“ |
菜单从已有授权且可见的 PC èœå•映射,登录后重新获取移动菜单即可显示。不会自动授予权限。质检使用 `mes:qc-seed-quality:submit/query`;销售查询、拣货、运单、出库分别使用 `mes:wm-product-sales:query/stock/shipping/finish`。
质检支持袋码、托盘码和生产批次码三种入口,最终逐袋保存记录与流转库存。业务码和追溯码锁定同一对象,结果统一存袋追溯码,防止双码重复质检。不合格必须填写原因并确认报废后提交;合格流转至合格库。四项指标上下限沿用现有录入方式,需要操作员按品种检验标准核对。
托盘、批次识别后显示有效袋数、已检袋数、本次待检袋数。作废袋排除,已检袋不重复处理;全部有效袋码入库后才允许提交。统一指标及结论应用于本次全部待检袋,操作者须确认抽检结果适用于该范围。提交携带识别时确认的袋 ID æ¸…单,服务端锁定并再次核对,清单变化需重新扫描;任何一袋失败,整次质检及库存操作回滚。
移动端新增 `POST /mes/qc/seed-quality/submit-group`,与原提交接口使用相同权限;识别接口增加 `scanType=BAG|PALLET|BATCH`。PC å•袋提交接口保持兼容。断线后按原确认袋清单核实是否已有逐袋质检记录,不自动重复提交。
出库先通过只读接口核对码值、品种、批次和袋数,点击确认才生成拣货明细。PC æ‰§è¡Œæ‹£è´§å’Œç§»åŠ¨ç«¯å‡æ”¯æŒè¢‹ç ã€æ‰˜ç›˜ç ã€ç”Ÿäº§æ‰¹æ¬¡ç ã€‚å•è¢‹æŒ‰ 1 è¢‹ï¼›æ‰˜ç›˜ã€æ‰¹æ¬¡éœ€è¦æ˜Žç¡®é€‰æ‹©å®žé™…袋码,可输入整数数量并按数量选袋,再逐袋调整。数量严格等于所选袋码数,不默认整托或整批全部拣货。
识别显示每袋追溯码、业务码及不可选原因,支持查找、勾选、清空和核对全部已选袋码。作废袋排除;未质检合格、不在合格库存、冻结、已出库或已加入未取消出库单的袋码不可选。预览不创建库存批次、明细或库存流水。提交再次核对袋码归属、状态、库存位置及计划余量,按每袋 1 è¢‹ç”Ÿæˆæ˜Žç»†ï¼Œä¿ç•™è¢‹ç  ID、托盘 ID、批次和来源码,执行出库逐袋更新库存及去向;部分托盘不把整个托盘标记为本单出库。
同单相同袋清单重试返回已有结果;部分重复要求重新核对,跨单重复及旧整托明细重叠均阻止。袋码、库存袋明细和出库单在事务内锁定;任何一袋写入失败整体回滚。已关联袋码的明细不能手工更改数量或库存,可在原 PC æ‹£è´§æµç¨‹åˆ é™¤åŽé‡æ–°æ‰«æé€‰æ‹©ã€‚库存最终仍由原执行出库流程扣减。
草稿维护、出库单创建/品种编辑、待检测处理继续使用现有 PC æµç¨‹ï¼›æœ¬æ¬¡ç§»åŠ¨é¡µé¢è¦†ç›–å·²ç¡®è®¤çš„çŽ°åœºè´¨æ£€å’Œé”€å”®å‡ºåº“æ“ä½œã€‚
## ç”Ÿäº§å…¥åº“自动完成
用户确认的规则是:**生产批次的有效袋码全部入库后,批次自动进入已完成**。
- æ‰«è¢‹ç å›žå†™è¯¥è¢‹å…¥åº“标记,按实际袋码标记统计批次进度。
- æ‰«æ‰˜ç›˜ç åªå¯¹æœ¬æ‰˜å‰©ä½™æœªå…¥åº“有效袋码入库,并同步袋码标记。
- æ‰«æ‰¹æ¬¡ç åªå…¥åº“该批次剩余有效袋码,支持此前已经逐袋或逐托入库的批次。
- æ‰¹æ¬¡ç´¯è®¡æ•°é‡ä¸ä¾èµ–重复加计;全部有效袋码入库后置状态 30。空批次不自动完成,已作废批次不允许入库。
- å•袋全部扫完时同步完成所属托盘入库标记;整批入库时同步各托盘。PC åˆ—表和移动详情显示袋码、托盘的已入库/未入库状态。
- ä½œåºŸè¢‹ã€é€»è¾‘删除袋排除在入库及完成判断之外。数据库写入在原库存事务中执行,失败不回写完成状态,事件号幂等沿用原逻辑。
本地库 `127.0.0.1:3306/mom-thzy` æ£€æŸ¥å‘现批次 2 çš„ 48 ä¸ªæœ‰æ•ˆè¢‹ç å‡å·²å…¥åº“,但状态仍为生产中;已在完整袋数和状态条件校验下修正为已完成。托盘 2 åŒæ­¥è¡¥é½å…¥åº“标记,事件号取关联袋码最大入库事件 7、时间取最后一袋实际入库时间。本次没有新增库存事务,没有变更袋码、品种或完整性摘要。
## æ–­çº¿å¤„理
质检和销售写入提交前保存当前账号的待确认操作。刷新后先核实质检结果或出库单状态及拣货明细,不自动提交下一袋或自动重试未知操作。当前未发现结果时需要操作者确认原请求已结束并重新核对;服务端仍执行重复校验。没有新增离线自动写入。
新增只读辅助接口:`GET /mes/qc/seed-quality/resolve`(质检提交权限)、`GET /mes/wm/product-sales/scan-preview`(拣货权限)。它们不创建质检记录、拣货明细或库存流水。拣货预览增加 `scanType=BAG|PALLET|BATCH` å’Œ `salesId`;原 `POST /mes/wm/product-sales/scan` å¢žåŠ  `scanType`、`bagIds`,`quantity` å¿…须等于所选袋数,托盘/批次缺少明确袋清单会拒绝提交。断线恢复按所选全部袋 ID æ ¸å®žï¼Œéƒ¨åˆ†å­˜åœ¨æ—¶è¦æ±‚负责人核对。
## éªŒè¯ä¸Žå¯ç”¨
后端相关回归共 45 é¡¹é€šè¿‡ï¼ŒåŒ…含真实 Mapper SQL åœ¨ H2 MySQL æ¨¡å¼ä¸‹çš„完成状态验证、混扫剩余袋数、作废排除、双码质检防重复及授权菜单映射。
三种质检入口扩展另通过 11 é¡¹åŽç«¯æµ‹è¯•,覆盖识别范围、已检排除、未全部入库阻止提交、待检清单变化、作废对象及真实 Spring äº‹åŠ¡æ•´ä½“å›žæ»šã€‚
三种入口浏览器模拟测试通过:袋码、托盘码、生产批次码识别时不写库存,提交携带确认的待检袋清单,断线后刷新核实已有记录且不重复提交。整托、整批页面沿用现有样式,480px æˆªå›¾å·²æ£€æŸ¥ï¼›ç”Ÿäº§æž„建通过。全项目类型检查的既存错误仍存在,本次质检页面、接口及移动入口文件无类型错误。
托盘、生产批次选择袋码拣货扩展通过 28 é¡¹åŽç«¯åŠç›¸å…³è´¨æ£€æµ‹è¯•,包括实际 Mapper SQL、跨单重复、旧整托重叠、确认数量/归属校验、按实际库位逐袋生成明细、响应关联字段及 Spring äº‹åŠ¡æ•´ä½“å›žæ»šã€‚ç§»åŠ¨æµè§ˆå™¨æ¨¡æ‹Ÿæµ‹è¯•è¦†ç›–æŒ‰æ•°é‡é€‰æ‹©ã€é€è¢‹è°ƒæ•´ã€ä¸å¯é€‰è¢‹ã€éƒ¨åˆ†æ‰˜ç›˜æ‹£è´§åŽç»§ç»­æ‰¹æ¬¡æ‹£è´§ã€æ–­çº¿æ ¸å®žåŠ 480/320px å¸ƒå±€ã€‚PC æµè§ˆå™¨æ¨¡æ‹Ÿæµ‹è¯•通过批次识别、部分选袋、数量一致和确认提交,预览不写入。最终生产构建通过;拣货接口、移动页面及 PC æ‹£è´§ç›¸å…³æ–‡ä»¶æ— æ–°å¢žç±»åž‹é”™è¯¯ã€‚未向本地数据库写入测试拣货或出库流水,本次扩展不需要数据库结构变更。
前端生产构建通过。新增浏览器模拟测试覆盖质检断线恢复、记录查询、只读扫码核对、拣货断线恢复、运单断线恢复、执行出库、权限隔离及 480/320px å®½åº¦ï¼›åŽŸç§»åŠ¨ç«¯æ‰«ç å…¥åº“/绑定/库存/登录测试通过。截图在 `.export_tmp/mobile-quality-sales/`。浏览器测试使用模拟接口,没有实际写入质检或销售库存。全项目类型检查仍有既存错误,本次修改涉及的移动端与接口、袋码/托盘列表文件无类型错误。
生效需要更新并重启后端、更新前端,然后重新登录移动端获取菜单。当前默认生产构建沿用项目已有 TEST å…¬å¸é…ç½®ï¼Œå…¶ API æŒ‡å‘ `http://1.95.78.124:9008/admin-api`;本地 5666 çŽ¯å¢ƒéƒ¨ç½²åº”ä½¿ç”¨è¯¥çŽ¯å¢ƒåŽŸæœ‰æž„å»º/API é…ç½®ã€‚未自动替换在线页面或重启正在运行的后端。本次代码不需要新增数据库字段。
scripts/browser-startup-smoke.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
const { chromium } = require('playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
(async () => {
  const root = path.resolve('mom-pro2-before/dist');
  const script = fs.readFileSync(path.join(root, 'startup-check.js'), 'utf8');
  const diagnostic = fs.readFileSync(path.join(root, 'browser-check.html'), 'utf8');
  assert.ok(fs.readFileSync(path.join(root, 'index.html'), 'utf8').includes('data-startup-check'));
  const browser = await chromium.launch({ headless: true, channel: 'msedge' });
  try {
    const page = await browser.newPage({ viewport: { width: 480, height: 800 } });
    page.on('pageerror', error => console.error('Browser error:', error.message));
    await page.route('http://startup.test/**', route => {
      const pathname = new URL(route.request().url()).pathname;
      if (pathname.endsWith('startup-check.js')) return route.fulfill({ contentType: 'application/javascript', body: script });
      if (pathname.endsWith('browser-check.html')) return route.fulfill({ contentType: 'text/html', body: diagnostic });
      return route.fulfill({ contentType: 'text/html', body: '<div id="__app-loading__">启动中</div><script data-startup-check src="/nested/startup-check.js"></script>' });
    });
    await page.clock.install();
    await page.goto('http://startup.test/nested/');
    await page.evaluate(() => window.dispatchEvent(new ErrorEvent('error', { message: "Unexpected token '?'" })));
    await page.clock.fastForward(31000);
    assert.match(await page.locator('#mom-startup-error').innerText(), /Unexpected token/);
    assert.equal(await page.locator('#mom-startup-error a').getAttribute('href'), 'http://startup.test/nested/browser-check.html');
    await page.evaluate(() => window.__MOM_STARTUP__.finish());
    assert.equal(await page.locator('#mom-startup-error').count(), 0);
    await page.goto('http://startup.test/nested/');
    await page.evaluate(() => window.__MOM_STARTUP__.finish());
    await page.clock.fastForward(31000);
    assert.equal(await page.locator('#mom-startup-error').count(), 0);
    await page.goto('http://startup.test/nested/');
    await page.evaluate(() => window.__MOM_STARTUP__.fail(new Error('<script>bad</script>')));
    assert.match(await page.locator('#mom-startup-error').innerText(), /<script>bad<\/script>/);
    assert.equal(await page.locator('#mom-startup-error script').count(), 0);
    await page.locator('#mom-startup-error a').click();
    assert.equal(await page.locator('#checks tr').count(), 10);
    assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
    console.log('PASS: diagnostic page, startup failure/timeout, success cleanup, safe text, nested base URL');
  } finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
scripts/code-toolbar-smoke.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
const { chromium } = require('playwright');
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const base = process.env.MOBILE_TEST_URL || 'http://127.0.0.1:5173';
const out = path.resolve('.export_tmp/code-toolbar'); fs.mkdirSync(out, { recursive: true });
(async () => {
 const browser = await chromium.launch({ headless: true, channel: 'msedge' });
 try {
  const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
  const page = await context.newPage(), errors = [], calls = [];
  page.on('pageerror', error => errors.push(error.message));
  const permissions = ['bag-code','pallet','batch'].flatMap(kind => ['query','create','update','replace','delete','export'].map(action => `mes:pro-${kind}:${action}`));
  await context.route('**/*', async route => {
   const url = new URL(route.request().url()), p = url.pathname;
   if (url.origin !== base) return route.abort();
   if (!p.startsWith('/admin-api/')) return route.continue();
   calls.push(p);
   let data = null;
   if (p.endsWith('/auth/login')) data = { accessToken:'fixture-token', refreshToken:'fixture-refresh', userId:7, expiresTime:Date.now()+3600000 };
   else if (p.endsWith('/get-permission-info')) data = { user:{ id:7, nickname:'测试员', username:'fixture' }, roles:['worker'], permissions,
    menus:[{ id:1, parentId:0, name:'一袋一码管理', path:'/bagcode', component:'Layout', visible:true, children:
     [['bag-code','bagCode','赋码管理'],['pallet','pallet','托盘码管理'],['batch','batch','批次生产管理']].map(([key,folder,name],i)=>({id:i+2,parentId:1,path:key,name,component:`mes/pro/${folder}/index`,visible:true})) }] };
   else if (p.endsWith('/bag-code/page')) data = { list:[{id:11,code:'6028472024079516',businessCode:'8888888888888888',itemName:'玉米种子',status:10}], total:1 };
   else if (p.endsWith('/pallet/page')) data = { list:[{id:12,code:'P-TEST',businessCode:'7777777777777777',bagCount:1,status:10}], total:1 };
   else if (p.endsWith('/batch/page')) data = { list:[{id:20,code:'B-TEST',businessCode:'6666666666666666',status:20}], total:1 };
   else if (p.endsWith('/pallet/unbound-list')) data = [
    {id:1,tempCode:'P-ONE',itemId:1,itemName:'塔河4号',itemCode:'ITEM-1',bagCount:25},
    {id:2,tempCode:'P-TWO',itemId:1,itemName:'塔河4号',itemCode:'ITEM-1',bagCount:25},
    {id:3,tempCode:'P-OTHER',itemId:2,itemName:'其他品种',itemCode:'ITEM-2',bagCount:25},
    {id:4,tempCode:'P-EMPTY',bagCount:0},
   ];
   else if (p.includes('export')) return route.fulfill({contentType:'application/zip',body:'fixture',headers:{'content-disposition':'attachment;filename=fixture.zip'}});
   else if (p.endsWith('/generate-business-code')) data = 1;
   else if (p.endsWith('/page')) data = {list:[],total:0};
   else if (p.endsWith('/simple-list') || p.endsWith('/list-all-simple') || p.endsWith('/get-unread-count')) data = [];
   return route.fulfill({json:{code:0,msg:'',data}});
  });
  await page.goto(base+'/auth/login');
  await page.locator('input[autocomplete=username]').fill('fixture');
  await page.locator('input[autocomplete=current-password]').fill('fixture-password');
  await page.getByRole('button',{name:'登录 PC ç«¯',exact:true}).click();
  await page.waitForURL(url => !url.pathname.startsWith('/auth/'));
  for (const [key,title,code] of [['bag-code','袋码列表(一袋一码)','8888888888888888'],['pallet','托盘码列表','7777777777777777'],['batch','生产批次列表','6666666666666666']]) {
   await page.goto(base+'/bagcode/'+key);
   await page.getByText(title,{exact:true}).waitFor();
   await page.getByText(code,{exact:true}).first().waitFor();
   const toolbar = page.locator('.mes-code-toolbar');
   for (const width of [1920,1280]) {
    await page.setViewportSize({width,height:1080});
    const buttons = await toolbar.locator('button').evaluateAll(nodes => nodes.map(node => ({text:node.textContent.trim(),y:node.getBoundingClientRect().top,x:node.getBoundingClientRect().right})));
    assert(buttons.length <= 6, 'Too many visible toolbar operations');
    assert(Math.max(...buttons.map(b=>b.y))-Math.min(...buttons.map(b=>b.y)) < 4, JSON.stringify(buttons));
    assert(buttons.every(b=>b.x <= width), 'Toolbar exceeds screen');
    const heading = await page.getByText(title,{exact:true}).boundingBox();
    assert(heading.height < 30, 'Title wraps');
    await page.screenshot({path:path.join(out,`${key}-${width}.png`),fullPage:true});
   }
   await toolbar.getByRole('button',{name:'导出',exact:true}).click();
   await page.getByRole('menuitem',{name:key==='bag-code'?'业务二维码':key==='pallet'?'业务二维码(选中记录)':'Excel æ•°æ®',exact:true}).waitFor();
   await page.getByText(title,{exact:true}).click();
   const qr = page.getByRole('button',{name:'业务二维码',exact:true});
   if (key !== 'batch') {
    await qr.click();
    await page.getByRole('dialog',{name:'业务码二维码'}).waitFor();
    assert(await page.getByRole('dialog').getByText(code,{exact:true}).isVisible());
    await page.screenshot({path:path.join(out,`${key}-business-qr.png`)});
    await page.keyboard.press('Escape');
   }
  }
  await page.getByRole('button',{name:'新增批次',exact:true}).click();
  const dialog=page.getByRole('dialog');await dialog.waitFor();
  const select=dialog.locator('.ant-select').last();
  await select.click();
  const first=page.locator('.ant-select-item-option').filter({hasText:'P-ONE'});
  await first.click();
  const itemFields=dialog.getByPlaceholder('选择托盘后自动带入');
  await page.waitForFunction(()=>[...document.querySelectorAll('input')].some(input=>input.value==='塔河4号'));
  assert.equal(await itemFields.nth(0).inputValue(),'塔河4号');
  assert.equal(await itemFields.nth(1).inputValue(),'ITEM-1');
  const other=page.locator('.ant-select-item-option').filter({hasText:'P-OTHER'});
  assert((await other.getAttribute('class')).includes('ant-select-item-option-disabled'));
  const empty=page.locator('.ant-select-item-option').filter({hasText:'P-EMPTY'});
  assert((await empty.getAttribute('class')).includes('ant-select-item-option-disabled'));
  await page.locator('.ant-select-item-option').filter({hasText:'P-TWO'}).click();
  await dialog.click({position:{x:10,y:10}});
  await page.screenshot({path:path.join(out,'batch-variety-binding.png'),fullPage:true});
  await page.keyboard.press('Escape');
  if(await dialog.isVisible()) await dialog.getByRole('button',{name:/取\s*消/}).click();
  assert.deepEqual(errors,[]);
  console.log('PASS: three code pages, grouped toolbar at 1920/1280, export dropdown, business QR preview');
 } finally { await browser.close(); }
})().catch(error=>{console.error(error);process.exitCode=1;});
scripts/mobile-quality-sales-smoke.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
const { chromium } = require('playwright');
const fs = require('fs'), path = require('path'), assert = require('assert/strict');
const base = process.env.MOBILE_TEST_URL || 'http://127.0.0.1:5174';
const out = path.resolve('.export_tmp/mobile-quality-sales'); fs.mkdirSync(out, { recursive: true });
(async () => {
  const browser = await chromium.launch({ headless: true, channel: 'msedge' });
  const context = await browser.newContext({ viewport: { width: 480, height: 800 } });
  const page = await context.newPage(), errors = [], writes = [], picked = [];
  let quality = null, salesStatus = 2, firstScan = true, readOnly = false;
  const permissions = ['mes:qc-seed-quality:submit', 'mes:qc-seed-quality:query', 'mes:wm-product-sales:query', 'mes:wm-product-sales:stock', 'mes:wm-product-sales:shipping', 'mes:wm-product-sales:finish'];
  page.on('pageerror', e => errors.push(e.message));
  page.on('dialog', d => d.accept());
  await context.route('**/*', async route => {
    const req = route.request(), u = new URL(req.url()), p = u.pathname;
    if (u.origin !== base) return route.abort();
    if (!p.startsWith('/admin-api/')) return route.continue();
    let data = null;
    if (p.endsWith('/auth/login')) data = { accessToken: 'test-token', refreshToken: 'test-refresh', userId: 7, expiresTime: Date.now() + 3600000 };
    else if (p.endsWith('/get-permission-info')) data = { user: { id: 7, nickname: '质检与仓库测试员', username: 'fixture' }, roles: ['worker'], permissions: readOnly ? ['mes:wm-product-sales:query'] : permissions, menus: ['quality', 'quality-records', 'sales'].map((key, i) => ({ id: i + 1, path: '/mobile/' + key, name: key, visible: true, component: 'mobile/' + key })) };
    else if (p.endsWith('/warehouse/simple-list')) data = [{ id: 8, name: '种子合格库', type: 20 }, { id: 9, name: '冻结合格库', type: 20, frozen: true }, { id: 10, name: '暂存库', type: 10 }];
    else if (p.endsWith('/seed-quality/resolve')) {
      const type = u.searchParams.get('scanType') || 'BAG';
      data = { code: type === 'BAG' ? 'TRACE-1' : type + '-TEST', businessCode: '8888888888888888', batchCode: 'BATCH-1', scanType: type, inbound: true, canceled: false, qualityId: quality?.id, result: quality?.result, totalCount: type === 'BAG' ? 1 : 3, pendingBagIds: quality ? [] : type === 'BAG' ? [1] : [2,3], completedBagIds: quality ? type === 'BAG' ? [1] : [1,2,3] : type === 'BAG' ? [] : [1] };
    }
    else if (p.endsWith('/seed-quality/submit-group')) {
      const body = req.postDataJSON(); writes.push({ quality: body });
      quality = { id: 101, code: body.code, batchCode: 'BATCH-1', result: 1, inspectedAt: '2026-10-10 12:00:00' };
      return route.abort('timedout');
    } else if (p.endsWith('/seed-quality/page')) data = { list: quality ? [quality] : [], total: quality ? 1 : 0 };
    else if (p.endsWith('/product-sales/page')) data = { list: [{ id: 11, code: 'SALE-1', clientName: '测试经销商', status: salesStatus }], total: 1 };
    else if (p.endsWith('/product-sales/get')) data = { id: 11, code: 'SALE-1', clientName: '测试经销商', contactName: '现场收货员', contactAddress: '南通市测试仓库', status: salesStatus, carrier: salesStatus === 3 || salesStatus === 4 ? '顺丰' : '', shippingNumber: salesStatus === 3 || salesStatus === 4 ? 'SF12345' : '' };
    else if (p.endsWith('/product-sales-line/page')) data = { list: [{ id: 21, itemId: 1, itemName: '塔河4号', quantity: 2, unitMeasureName: '袋' }], total: 1 };
    else if (p.endsWith('/product-sales-detail/list-by-line')) data = picked;
    else if (p.endsWith('/product-sales/scan-preview')) {
      const type = u.searchParams.get('scanType') || 'BAG', bagId = u.searchParams.get('code') === 'BAG-2' ? 32 : 31;
      const options = (type === 'BAG' ? [bagId] : [31,32,33]).map(id => ({ id, code: 'TRACE-' + id, businessCode: 'BUS-' + id, selectable: id !== 33 && !picked.some(d => d.bagCodeId === id), unavailableReason: id === 33 ? '尚未质检合格' : picked.some(d => d.bagCodeId === id) ? '已加入出库单拣货' : undefined }));
      data = { scanType: type, codeId: type === 'BAG' ? bagId : type === 'PALLET' ? 200 : 100, scanContent: u.searchParams.get('code'), itemId: 1, batchCode: 'BATCH-1', scanQuantity: options.filter(b => b.selectable).length, bags: options };
    }
    else if (p.endsWith('/product-sales/scan')) {
      const body = req.postDataJSON(); writes.push({ scan: body });
      assert.equal(body.quantity, body.bagIds.length);
      for (const bagId of body.bagIds) picked.push({ id: 50 + picked.length, lineId: 21, bagCodeId: bagId, quantity: 1, batchCode: 'BATCH-1', warehouseName: '种子合格库', locationName: '一区', areaName: 'A01', remark: '袋码 TRACE-' + bagId });
      if (firstScan) { firstScan = false; return route.abort('timedout'); }
      data = { outboundQuantity: 1, alreadyScanned: false };
    } else if (p.endsWith('/product-sales/check-quantity')) data = picked.length === 2;
    else if (p.endsWith('/product-sales/stock')) { writes.push({ stock: true }); salesStatus = 10; data = true; }
    else if (p.endsWith('/product-sales/shipping')) { writes.push({ shipping: req.postDataJSON() }); salesStatus = 3; return route.abort('timedout'); }
    else if (p.endsWith('/product-sales/finish')) { writes.push({ finish: true }); salesStatus = 4; data = true; }
    else if (p.endsWith('/simple-list') || p.endsWith('/get-unread-count')) data = [];
    return route.fulfill({ json: { code: 0, data, msg: '' } });
  });
  async function scan(value) { await page.getByRole('textbox', { name: '码值 / æº¯æºé“¾æŽ¥' }).fill(value); await page.getByRole('button', { name: '识别码值', exact: true }).click(); }
  async function noOverflow() { assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Page must fit mobile width'); }
  async function capture(name) { await page.locator('#__app-loading__').waitFor({ state: 'detached' }); await page.evaluate(() => document.activeElement?.blur()); await page.waitForFunction(() => { window.scrollTo({ top: 0, behavior: 'instant' }); return window.scrollY <= 1; }); await page.screenshot({ path: path.join(out, name), fullPage: true }); }
  try {
    await page.goto(base + '/auth/login');
    await page.getByRole('radio', { name: '移动端', exact: true }).check();
    await page.locator('input[autocomplete=username]').fill('fixture'); await page.locator('input[autocomplete=current-password]').fill('fixture');
    await page.getByRole('button', { name: '登录移动端', exact: true }).click(); await page.waitForURL('**/mobile/home');
    await page.locator('#__app-loading__').waitFor({ state: 'detached' });
    await page.getByRole('link', { name: '种子质检 è¢‹ç ã€æ‰˜ç›˜ä¸Žæ‰¹æ¬¡' }).waitFor();
    await capture('home-480.png');
    await page.goto(base + '/mobile/quality'); await scan('8888888888888888');
    await page.getByRole('heading', { name: '本次检验对象' }).waitFor(); assert.equal(writes.length, 0);
    for (const field of await page.getByRole('spinbutton', { name: '实测值', exact: true }).all()) await field.fill('90');
    await page.getByRole('spinbutton', { name: '合格下限', exact: true }).first().fill('95');
    assert(await page.getByRole('textbox', { name: '不合格原因(必填)' }).evaluate(el => el.required));
    await page.getByRole('spinbutton', { name: '合格下限', exact: true }).first().fill('0');
    await page.getByRole('button', { name: '目标合格库 è¯·é€‰æ‹©', exact: true }).click();
    assert.equal(await page.getByRole('button', { name: '冻结合格库', exact: true }).count(), 0);
    await page.getByRole('button', { name: '种子合格库', exact: true }).click();
    await noOverflow(); await capture('quality-480.png');
    await page.getByRole('button', { name: '确认提交质检', exact: true }).click();
    await page.getByRole('heading', { name: '质检结果待确认' }).waitFor(); assert.equal(writes.length, 1); assert.equal(writes[0].quality.code, 'TRACE-1');
    await page.reload(); await page.getByRole('button', { name: '核实质检结果' }).click();
    await page.getByText(/已核实:本次袋码均已有质检记录/).waitFor(); assert.equal(writes.length, 1, 'Verification must not resubmit quality');
    await page.goto(base + '/mobile/quality-records'); await page.getByText('合格', { exact: true }).last().waitFor();
    await capture('quality-records-480.png');
    for (const type of ['PALLET', 'BATCH']) {
      quality = null;
      await page.goto(base + '/mobile/quality');
      await page.getByRole('combobox', { name: '质检扫码类型' }).selectOption(type);
      await scan(type + '-TEST'); await page.getByRole('heading', { name: '本次检验对象' }).waitFor();
      await page.getByText('2 è¢‹', { exact: true }).waitFor();
      const previousWrites = writes.length;
      for (const field of await page.getByRole('spinbutton', { name: '实测值', exact: true }).all()) await field.fill('90');
      await page.getByRole('button', { name: '目标合格库 è¯·é€‰æ‹©', exact: true }).click();
      await page.getByRole('button', { name: '种子合格库', exact: true }).click();
      await noOverflow(); await capture('quality-' + type.toLowerCase() + '-480.png');
      assert.equal(writes.length, previousWrites, 'Group recognition must not write inventory');
      await page.getByRole('button', { name: '确认提交质检' }).click();
      await page.getByRole('heading', { name: '质检结果待确认' }).waitFor();
      assert.equal(writes.at(-1).quality.scanType, type); assert.deepEqual(writes.at(-1).quality.bagIds, [2,3]);
      await page.reload(); await page.getByRole('button', { name: '核实质检结果' }).click();
      await page.getByText(/已核实:本次袋码均已有质检记录/).waitFor();
      assert.equal(writes.length, previousWrites + 1, 'Group recovery must not resubmit');
    }
    await page.goto(base + '/mobile/sales'); await page.getByRole('button', { name: 'SALE-1 å¾…拣货 æµ‹è¯•经销商' }).click();
    await scan('BAG-1'); await page.getByRole('heading', { name: '核对拣货对象' }).waitFor(); assert.equal(writes.length, 3);
    await page.getByRole('button', { name: '取消本次扫码' }).click();
    await page.getByRole('combobox', { name: '拣货扫码类型' }).selectOption('PALLET');await scan('PALLET-200');
    assert(await page.getByRole('button', { name: '确认加入拣货' }).isDisabled());
    assert(await page.getByRole('checkbox', { name: '选择袋码 TRACE-33', exact: true }).isDisabled());
    await page.getByRole('spinbutton', { name: '本次拣货数量' }).fill('1');await page.getByRole('button', { name: '按数量选择袋码' }).click();
    await page.getByRole('checkbox', { name: '选择袋码 TRACE-31', exact: true }).uncheck();await page.getByRole('checkbox', { name: '选择袋码 TRACE-32', exact: true }).check();
    await noOverflow(); await capture('sales-picking-480.png');
    await page.getByRole('button', { name: '确认加入拣货' }).click(); await page.getByRole('heading', { name: '出库操作结果待确认' }).waitFor();
    await page.reload(); await page.getByRole('button', { name: '核实操作结果' }).click(); await page.getByText('已核实:该码已在拣货清单中。').waitFor();
    assert.equal(writes.length, 4);assert.deepEqual(writes.at(-1).scan.bagIds, [32]);assert.equal(writes.at(-1).scan.scanType, 'PALLET');
    await page.getByRole('combobox', { name: '拣货扫码类型' }).selectOption('BATCH');await scan('BATCH-100');
    assert(await page.getByRole('checkbox', { name: '选择袋码 TRACE-32', exact: true }).isDisabled());
    await page.getByRole('spinbutton', { name: '本次拣货数量' }).fill('1');await page.getByRole('button', { name: '按数量选择袋码' }).click();
    await capture('sales-batch-selection-480.png');await page.setViewportSize({width:320,height:640});await noOverflow();await capture('sales-batch-selection-320.png');await page.setViewportSize({width:480,height:800});
    await page.getByRole('button', { name: '确认加入拣货' }).click();
    await page.getByText('已加入拣货:1 è¢‹ã€‚').waitFor();assert.deepEqual(writes.at(-1).scan.bagIds,[31]);assert.equal(writes.at(-1).scan.scanType,'BATCH'); await page.getByRole('button', { name: '完成拣货并核对数量' }).click();
    await page.getByRole('heading', { name: '填写运单', exact: true }).waitFor();
    await page.getByRole('textbox', { name: '承运商', exact: true }).fill('顺丰'); await page.getByRole('textbox', { name: '运输单号', exact: true }).fill('SF12345');
    await capture('sales-shipping-480.png');
    await page.getByRole('button', { name: '保存运单' }).click(); await page.getByRole('heading', { name: '出库操作结果待确认' }).waitFor();
    await page.getByRole('button', { name: '核实操作结果' }).click(); await page.getByRole('button', { name: '确认执行出库' }).click();
    await page.getByText('该单已完成出库。').waitFor(); assert.equal(writes.length, 8); await noOverflow();
    await capture('sales-finished-480.png');
    await page.setViewportSize({ width: 320, height: 640 }); await noOverflow();
    await capture('sales-finished-320.png');
    readOnly = true; await page.reload(); await page.goto(base + '/mobile/quality'); await page.waitForURL('**/mobile/home**');
    assert.equal(await page.getByRole('link', { name: /种子质检/ }).count(), 0);
    assert.deepEqual(errors, []); console.log('PASS: bag/pallet/batch quality submit/timeout recovery, records, sales preview/picking/shipping/outbound, 480/320 widths and permissions');
  } finally { await browser.close(); }
})().catch(e => { console.error(e); process.exitCode = 1; });
scripts/mobile-ui-smoke.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,172 @@
const { chromium } = require('playwright');
const fs=require('fs'), path=require('path'), assert=require('assert/strict');
const base=process.env.MOBILE_TEST_URL||'http://127.0.0.1:5173';
const out=path.resolve('.export_tmp/mobile-ui');fs.mkdirSync(out,{recursive:true});
(async()=>{
 const browser=await chromium.launch({headless:true,channel:'msedge'});
 const context=await browser.newContext({viewport:{width:480,height:800}});
 await context.addInitScript(() => {
   if (navigator.mediaDevices) Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {
     value: () => Promise.reject(new DOMException('测试设备未配置相机', 'NotFoundError')),
   });
 });
 const page=await context.newPage(),errors=[],calls=[],submissions=[];
 page.on('pageerror',e=>errors.push(e.message));
 let restricted=false, firstInbound=true,permissionFails=false,failStockMore=true;
 const permissions=['mes:pro-scan-in:inbound','mes:pro-scan-in:query','mes:pro-bag-code:query','mes:pro-bag-code:update','mes:pro-pallet:query','mes:pro-pallet:update','mes:pro-batch:query','mes:wm-material-stock:query'];
 const event={id:31,eventId:'fixture',bagCode:'6028472024079516',status:'SUCCESS',scanType:'BAG',quantity:1,batchCode:'PB-TEST',receivedTime:'2026-10-10 08:30:00'};
 await context.route('**/*',async r=>{
  const u=new URL(r.request().url()),p=u.pathname;
  if(u.origin!==base){return r.abort();}
  if(!p.startsWith('/admin-api/'))return r.continue();
  calls.push(p+u.search);
  let data=null;
  if(p.endsWith('/auth/login'))data={accessToken:'fixture-token',refreshToken:'fixture-refresh',userId:7,expiresTime:Date.now()+3600000};
  else if(p.endsWith('/get-permission-info')){
   if(permissionFails)return r.fulfill({json:{code:500,msg:'测试:菜单暂时不可用'}});
   const mobile=u.searchParams.get('terminalType')==='mobile';
   data={user:{id:7,nickname:'现场测试员',username:'fixture'},roles:['worker'],permissions:restricted?[]:permissions,menus:mobile?['inbound','bags','pallets','batches','stock'].map((key,i)=>({id:i+1,path:'/mobile/'+key,name:key,visible:true,component:'mobile/'+key})):[]};
  }else if(p.endsWith('/warehouse/simple-list'))data=[{id:1,code:'WIP_VIRTUAL_WAREHOUSE',name:'生产暂存仓'}];
  else if(p.endsWith('/warehouse-location/simple-list'))data=[{id:2,name:'生产一区',frozen:false}];
  else if(p.endsWith('/warehouse-area/simple-list'))data=[{id:3,name:'A01',status:0,frozen:false},{id:4,name:'停用库位',status:1,frozen:false}];
  else if(p.endsWith('/resolve-type'))data=u.searchParams.get('code').startsWith('P')?'pallet':'bag';
  else if(p.endsWith('/bag-code/bind-pallet')){submissions.push({binding:r.request().postDataJSON()});data=true;}
  else if(p.endsWith('/bag-code/get-by-code'))data={id:11,code:u.searchParams.get('code')==='8888888888888888'?'6028472024079516':u.searchParams.get('code'),businessCode:'8888888888888888',itemName:'玉米种子',batchId:20,batchCode:'PB-TEST',produceDate:'2026-10-09',lineName:'一号产线'};
  else if(p.endsWith('/pallet/get-by-code'))data={id:12,code:u.searchParams.get('code'),batchId:20,batchCode:'PB-TEST',itemName:'玉米种子'};
  else if(p.endsWith('/scan-in/my-event'))data=null;
  else if(p.endsWith('/scan-in/inbound')){
    const body=r.request().postDataJSON();submissions.push(body);
    if(firstInbound){firstInbound=false;return r.abort('timedout');}
    data={...event,eventId:body.eventId};
  }
  else if(p.endsWith('/scan-in/my-page'))data={list:[event],total:1};
  else if(p.endsWith('/material-stock/page')){
 const more=u.searchParams.get('pageNo')==='2';
 if(more&&failStockMore){failStockMore=false;return r.abort('timedout');}
 data={list:[{id:more?2:1,itemName:more?'第二条库存':'玉米种子',batchCode:'PB-TEST',quantity:25,availableQuantity:22,unitMeasureName:'袋',warehouseName:'生产暂存仓',locationName:'生产一区',areaName:'A01'}],total:2};
 }
  else if(p.endsWith('/page'))data={list:[],total:0};
  else if(p.endsWith('/simple-list')||p.endsWith('/list-all-simple')||p.endsWith('/get-unread-count'))data=[];
  return r.fulfill({json:{code:0,msg:'',data}});
 });
 await page.goto(base+'/auth/login');await page.getByRole('radio',{name:'移动端',exact:true}).waitFor();
 assert(await page.getByRole('radio',{name:'移动端',exact:true}).isChecked());
 await page.getByRole('radio',{name:'PC ç«¯',exact:true}).check();
 await page.setViewportSize({width:390,height:800});
 assert(await page.getByRole('radio',{name:'PC ç«¯',exact:true}).isChecked());
 await page.getByRole('radio',{name:'移动端',exact:true}).check();await page.setViewportSize({width:480,height:800});
 await page.locator('input[autocomplete=username]').fill('fixture');
 await page.locator('input[autocomplete=current-password]').fill('fixture-password');
 await page.screenshot({path:path.join(out,'login-480.png'),fullPage:true});
 await page.getByRole('button',{name:'登录移动端',exact:true}).click();
 await page.waitForURL('**/mobile/home');
 await page.getByRole('heading',{name:'从一次扫码开始'}).waitFor();
 await page.screenshot({path:path.join(out,'home-480.png'),fullPage:true});
 assert(calls.some(c=>c.includes('terminalType=mobile')));
 await page.goto(base+'/mobile/inbound');
 await page.getByRole('button',{name:'库区 è¯·é€‰æ‹©åº“区',exact:true}).click();
 await page.getByRole('textbox',{name:'搜索库区'}).fill('生产');
 for(let i=0;i<4;i++){await page.keyboard.press('Tab');assert(await page.getByRole('dialog',{name:'选择库区'}).evaluate(el=>el.contains(document.activeElement)));}
 await page.keyboard.press('Escape');
 await page.getByRole('dialog',{name:'选择库区'}).waitFor({state:'hidden'});
 assert.equal(await page.evaluate(()=>document.body.style.overflow),'');
 await page.getByRole('button',{name:'库区 è¯·é€‰æ‹©åº“区',exact:true}).click();
 await page.getByRole('textbox',{name:'搜索库区'}).fill('生产');
 await page.getByRole('button',{name:'生产一区',exact:true}).click();
 await page.getByRole('button',{name:'库位 è¯·é€‰æ‹©åº“位',exact:true}).click();
 assert.equal(await page.getByRole('button',{name:'停用库位',exact:true}).count(),0);
 await page.getByRole('textbox',{name:'搜索库位'}).fill('不存在');
 await page.getByText('没有匹配的库位').waitFor();
 await page.getByRole('textbox',{name:'搜索库位'}).fill('A01');
 await page.locator('#__app-loading__').waitFor({state:'detached'});
 await page.screenshot({path:path.join(out,'location-picker-480.png'),fullPage:true});
 await page.getByRole('button',{name:'A01',exact:true}).click();
 await page.getByRole('button',{name:'打开摄像头扫码'}).click();
 await page.getByRole('alert').waitFor();
 await page.getByRole('textbox',{name:'码值 / æº¯æºé“¾æŽ¥'}).fill('https://example.test/trace?code=6028472024079516');
 await page.getByRole('button',{name:'识别码值',exact:true}).click();
 await page.getByRole('heading',{name:'核对入库对象'}).waitFor();
 await page.screenshot({path:path.join(out,'inbound-confirm-480.png'),fullPage:true});
 assert.equal(submissions.length,0,'Recognition must not submit inventory');
 await page.getByRole('button',{name:'确认入库',exact:true}).click();
 await page.getByRole('heading',{name:'核实上次入库结果'}).waitFor();
 assert.equal(submissions.length,1);
 await page.reload();
 await page.getByRole('heading',{name:'核实上次入库结果'}).waitFor();
 await page.getByRole('button',{name:'核实结果',exact:true}).click();
 await page.getByText('尚未查到记录。请稍后核实,或使用原操作号重试。').waitFor();
 await page.getByRole('button',{name:'使用原操作号重试'}).click();
 await page.getByRole('heading',{name:'入库成功',exact:true}).waitFor();
 assert.equal(submissions.length,2);assert.deepEqual(submissions[0],submissions[1],'Timeout retry must preserve the entire payload');
 await page.getByRole('link',{name:'查看作业记录'}).click();
 await page.getByText('查询本人记录').waitFor();
 await page.screenshot({path:path.join(out,'records-480.png'),fullPage:true});
 assert(calls.some(c=>c.includes('/my-page')&&c.includes('receivedTime')));
 await page.goto(base+'/mobile/inbound');
 await page.getByRole('textbox',{name:'码值 / æº¯æºé“¾æŽ¥'}).fill('8888888888888888');
 await page.getByRole('button',{name:'识别码值',exact:true}).click();
 await page.getByRole('heading',{name:'核对入库对象'}).waitFor();
 assert.equal(submissions.length,2,'Business-code recognition must not submit inventory');
 await page.getByRole('button',{name:'确认入库',exact:true}).click();
 await page.getByRole('heading',{name:'入库成功',exact:true}).waitFor();
 assert.equal(submissions[2].bagCode,'8888888888888888');
 await page.goto(base+'/mobile/bags');
 await page.getByRole('button',{name:'开始袋码绑定托盘'}).click();
 await page.getByRole('textbox',{name:'码值 / æº¯æºé“¾æŽ¥'}).fill('PALLET-TEST');
 await page.getByRole('button',{name:'识别码值',exact:true}).click();
 await page.getByText('逐袋采集',{exact:true}).waitFor();
 await page.getByRole('textbox',{name:'码值 / æº¯æºé“¾æŽ¥'}).fill('6028472024079516');
 await page.getByRole('button',{name:'识别码值',exact:true}).click();
 await page.getByText('已采集 1 è¢‹ï¼Œå°šæœªæäº¤ç»‘定').waitFor();
 await page.getByRole('button',{name:'识别码值',exact:true}).click();
 await page.getByText('本次已经采集该袋码').waitFor();
 assert.equal(submissions.length,3);
 await page.getByRole('button',{name:'确认绑定',exact:true}).click();
 await page.getByText('绑定成功,已更新对象的绑定关系。').waitFor();
 assert.deepEqual(submissions[3],{binding:{ids:[11],palletId:12}});
 await page.goto(base+'/mobile/stock');await page.getByText('查询库存',{exact:true}).waitFor();
 await page.getByRole('button',{name:'查看数量明细'}).click();
 await page.getByText('在库数量',{exact:true}).waitFor();
 await page.screenshot({path:path.join(out,'stock-480.png'),fullPage:true});
 await page.getByRole('textbox',{name:'品种名称',exact:true}).fill('玉米');
 await page.getByRole('button',{name:'查询库存',exact:true}).click();
 await page.getByRole('button',{name:'查询库存',exact:true}).waitFor();
 await page.getByRole('textbox',{name:'品种名称',exact:true}).fill('尚未查询');
 await page.getByRole('button',{name:'加载更多',exact:true}).click();
 await page.getByRole('alert').waitFor();
 await page.getByRole('button',{name:'加载更多',exact:true}).click();
 await page.getByText('第二条库存',{exact:true}).waitFor();
 const stockMore=calls.filter(c=>c.includes('/material-stock/page')&&c.includes('pageNo=2'));
 assert.equal(stockMore.length,2);assert(stockMore.every(c=>c.includes('itemName=%E7%8E%89%E7%B1%B3')));
 const savedScroll=await page.evaluate(()=>{window.scrollTo(0,200);return window.scrollY;});
 await page.getByRole('button',{name:'返回上一页'}).click();
 await page.getByRole('link',{name:'作业',exact:true}).click();
 await page.getByRole('link',{name:'库存查询 å“ç§ã€æ‰¹æ¬¡ä¸Žåº“位',exact:true}).click();
 assert.equal(await page.getByRole('textbox',{name:'品种名称',exact:true}).inputValue(),'尚未查询');
 await page.waitForFunction(y=>Math.abs(window.scrollY-y)<2,savedScroll);
 await page.getByText('第二条库存',{exact:true}).waitFor();
 assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false);
 restricted=true;await page.goto(base+'/mobile/stock');
 await page.waitForURL('**/mobile/home?forbidden=1');await page.getByText('暂无移动端可用功能').waitFor();
 restricted=false;permissionFails=true;await page.goto(base+'/mobile/home');
 await page.waitForURL('**/auth/login?loadError=1');
 await page.getByText('菜单加载失败,请重新登录后重试。').waitFor();
 permissionFails=false;
 await page.getByRole('button',{name:'登录移动端',exact:true}).click();await page.waitForURL('**/mobile/home');
 await page.goto(base+'/mobile/mine');await Promise.all([page.waitForResponse(r=>r.url().includes('terminalType=pc')),page.getByRole('button',{name:'切换至 PC ç«¯'}).click()]);
 await page.waitForFunction(()=>sessionStorage.getItem('mom-terminal')==='pc');
 assert(calls.some(c=>c.includes('terminalType=pc')),'Switch must reload PC menus');
 await page.evaluate(()=>sessionStorage.setItem('mom-terminal','mobile'));await page.goto(base+'/mobile/mine');
 await page.getByRole('button',{name:'退出登录',exact:true}).click();await page.waitForURL('**/auth/login');
 assert.equal(await page.evaluate(()=>Object.keys(sessionStorage).some(k=>k.startsWith('mom-mobile-'))),false);
 await page.goto(base+'/mobile/inbound');await page.waitForURL('**/auth/login?redirect=*');
 const pc=await browser.newContext({viewport:{width:1280,height:800}});await pc.route('**/admin-api/**',r=>r.fulfill({json:{code:0,data:[]}}));
 const desktop=await pc.newPage();await desktop.goto(base+'/auth/login');
 assert(await desktop.getByRole('radio',{name:'PC ç«¯',exact:true}).isChecked());
 await desktop.screenshot({path:path.join(out,'login-pc.png'),fullPage:true});
 assert.deepEqual(errors,[]);
 await browser.close();console.log(JSON.stringify({passed:true,calls,submissions},null,2));
})().catch(e=>{console.error(e);process.exit(1);});
scripts/pc-sales-selection-smoke.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
const { chromium } = require('playwright');
const fs = require('fs'), path = require('path'), assert = require('assert/strict');
const base = process.env.MOBILE_TEST_URL || 'http://127.0.0.1:5174';
const out = path.resolve('.export_tmp/mobile-quality-sales');fs.mkdirSync(out,{recursive:true});
(async()=>{
  const browser=await chromium.launch({headless:true,channel:'msedge'});
  const context=await browser.newContext({viewport:{width:1440,height:1000}}),page=await context.newPage();
  const writes=[], errors=[];page.on('pageerror',e=>errors.push(e.message));
  const order={id:11,code:'SALE-1',name:'测试销售出库',clientId:1,clientName:'测试经销商',status:2,salesDate:'2026-10-10'};
  await context.route('**/*',async route=>{
    const req=route.request(),u=new URL(req.url()),p=u.pathname;
    if(u.origin!==base)return route.abort();if(!p.startsWith('/admin-api/'))return route.continue();
    let data=[];
    if(p.endsWith('/auth/login'))data={accessToken:'test-token',refreshToken:'test-refresh',userId:7,expiresTime:Date.now()+3600000};
    else if(p.endsWith('/get-permission-info'))data={user:{id:7,nickname:'仓库测试员',username:'fixture',homePath:'/wls/productsales'},roles:['worker'],permissions:['mes:wm-product-sales:query','mes:wm-product-sales:stock'],menus:[{id:1,name:'销售出库',path:'/wls/productsales',component:'wls/productsales/index',visible:true}]};
    else if(p.endsWith('/product-sales/page'))data={list:[order],total:1};
    else if(p.endsWith('/product-sales/get'))data=order;
    else if(p.endsWith('/product-sales-line/page'))data={list:[{id:21,itemId:1,itemName:'塔河4号',quantity:5,unitMeasureName:'袋'}],total:1};
    else if(p.endsWith('/product-sales/scan-preview'))data={scanType:u.searchParams.get('scanType'),codeId:100,scanContent:u.searchParams.get('code'),batchCode:'BATCH-1',itemId:1,scanQuantity:3,bags:[31,32,33].map(id=>({id,code:'TRACE-'+id,businessCode:'BUS-'+id,selectable:true}))};
    else if(p.endsWith('/product-sales/scan')){const body=req.postDataJSON();writes.push(body);data={outboundQuantity:body.bagIds.length,alreadyScanned:false};}
    else if(p.endsWith('/get-unread-count'))data=0;
    else if(p.endsWith('/page'))data={list:[],total:0};
    return route.fulfill({json:{code:0,data,msg:''}});
  });
  try{
    await page.goto(base+'/auth/login');await page.getByRole('radio',{name:'PC ç«¯',exact:true}).check();
    await page.locator('input[autocomplete=username]').fill('fixture');await page.locator('input[autocomplete=current-password]').fill('fixture');await page.getByRole('button',{name:'登录 PC ç«¯',exact:true}).click();
    await page.waitForURL('**/wls/productsales');await page.getByRole('button',{name:'拣货',exact:true}).first().click();
    await page.getByRole('heading',{name:'执行拣货',exact:true}).waitFor();
    await page.locator('.ant-select').filter({has:page.getByRole('combobox',{name:'拣货扫码类型'})}).click();await page.getByText('生产批次码',{exact:true}).click();
    await page.getByPlaceholder('扫描所选类型码,回车识别').fill('BATCH-100');await page.getByRole('button',{name:'识别码值',exact:true}).click();
    await page.getByRole('heading',{name:'核对拣货对象'}).waitFor();assert.equal(writes.length,0);
    await page.getByRole('spinbutton',{name:'本次拣货数量'}).fill('2');await page.getByRole('button',{name:'按数量选择袋码'}).click();
    await page.getByRole('checkbox',{name:'选择袋码 TRACE-31',exact:true}).uncheck();assert.equal(await page.getByRole('spinbutton',{name:'本次拣货数量'}).inputValue(),'1');await page.getByRole('checkbox',{name:'选择袋码 TRACE-33',exact:true}).check();
    await page.screenshot({path:path.join(out,'pc-sales-batch-selection.png'),fullPage:true});
    await page.getByRole('button',{name:'确认加入拣货'}).click();await page.getByRole('button',{name:/^ç¡®\s*认$/}).click();
    await page.getByText('已加入拣货 2 è¢‹ï¼Œé€è¢‹å…³è”追溯码',{exact:true}).waitFor();
    assert.equal(writes.length,1);assert.equal(writes[0].scanType,'BATCH');assert.equal(writes[0].quantity,2);assert.deepEqual(writes[0].bagIds,[32,33]);
    assert.deepEqual(errors,[]);console.log('PASS: PC batch partial picking, selected bag IDs and exact quantity; preview performs no writes');
  }catch(e){await page.screenshot({path:path.join(out,'pc-sales-selection-failure.png')});console.error(e);process.exitCode=1;}
  finally{await browser.close();}
})();
sql/mysql/mes_pro_bag_business_code.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
-- ä¸€è¢‹åŒç ï¼šcode ä¿æŒåŽŸè¿½æº¯ç ï¼Œbusiness_code ä¸ºå†…部扫码出入库的独立码值。
-- å…ˆæ‰§è¡Œæœ¬è„šæœ¬ï¼Œå†éƒ¨ç½²åŽç«¯ï¼›åŽ†å²æ•°æ®é€šè¿‡åŽå°å‹¾é€‰è¡¥ç”Ÿæˆï¼Œç¦æ­¢ç›´æŽ¥å¤åˆ¶è¿½æº¯ç ã€‚
SET @bag_business_column_exists = (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE() AND table_name = 'mes_pro_bag_code' AND column_name = 'business_code'
);
SET @bag_business_ddl = IF(@bag_business_column_exists = 0,
    'ALTER TABLE mes_pro_bag_code ADD COLUMN business_code VARCHAR(16) DEFAULT NULL COMMENT ''内部出入库业务码(16位数字)'' AFTER code', 'SELECT 1');
PREPARE bag_business_stmt FROM @bag_business_ddl;
EXECUTE bag_business_stmt;
DEALLOCATE PREPARE bag_business_stmt;
SET @bag_business_index_exists = (
    SELECT COUNT(*) FROM information_schema.statistics
    WHERE table_schema = DATABASE() AND table_name = 'mes_pro_bag_code' AND index_name = 'uk_business_code'
);
SET @bag_business_ddl = IF(@bag_business_index_exists = 0,
    'ALTER TABLE mes_pro_bag_code ADD UNIQUE KEY uk_business_code (business_code)', 'SELECT 1');
PREPARE bag_business_stmt FROM @bag_business_ddl;
EXECUTE bag_business_stmt;
DEALLOCATE PREPARE bag_business_stmt;
sql/mysql/mes_pro_batch_bag_code_init.sql
@@ -13,6 +13,7 @@
CREATE TABLE IF NOT EXISTS `mes_pro_batch` (
    `id`              BIGINT       NOT NULL AUTO_INCREMENT COMMENT '编号',
    `code`            VARCHAR(64)  DEFAULT NULL COMMENT '批次号(补录业务信息后自动生成;单次录入或补录流程必填)',
    `business_code` VARCHAR(16) DEFAULT NULL COMMENT '内部出入库业务码(16位数字)',
    `temp_code`       VARCHAR(64)  DEFAULT NULL COMMENT '临时批次码(PB-8位全局序号,预生成二维码阶段使用)',
    `item_id`         BIGINT       NOT NULL COMMENT '品种(产品物料)编号,关联 mdm_item.id',
    `line_id`         BIGINT       DEFAULT NULL COMMENT '产线编号,关联 mes_pro_line.id',
@@ -39,6 +40,7 @@
    `deleted`         BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_code` (`code`),
    UNIQUE KEY `uk_business_code` (`business_code`),
    UNIQUE KEY `uk_temp_code` (`temp_code`),
    KEY `idx_item_id` (`item_id`),
    KEY `idx_work_order_id` (`work_order_id`),
@@ -70,7 +72,9 @@
CREATE TABLE IF NOT EXISTS `mes_pro_pallet` (
    `id`         BIGINT       NOT NULL AUTO_INCREMENT COMMENT '编号',
    `code`       VARCHAR(64)  DEFAULT NULL COMMENT '托盘码(批次号-P+托盘序号,绑定批次后生成)',
    `business_code` VARCHAR(16) DEFAULT NULL COMMENT '内部出入库业务码(16位数字)',
    `temp_code`  VARCHAR(64)  DEFAULT NULL COMMENT '临时托盘码(PL-8位全局序号,预生成二维码阶段使用)',
    `item_id`    BIGINT       DEFAULT NULL COMMENT '托盘品种编号',
    `batch_id`   BIGINT       DEFAULT NULL COMMENT '生产批次编号,绑定批次后关联 mes_pro_batch.id',
    `batch_code` VARCHAR(64)  DEFAULT NULL COMMENT '批次号(冗余,便于展示)',
    `pallet_no`  INT          DEFAULT NULL COMMENT '批次内托盘序号(绑定批次后生成)',
@@ -86,6 +90,7 @@
    `deleted`    BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_code` (`code`),
    UNIQUE KEY `uk_business_code` (`business_code`),
    UNIQUE KEY `uk_temp_code` (`temp_code`),
    KEY `idx_batch_id` (`batch_id`)
) ENGINE = InnoDB COMMENT = 'MES æ‰˜ç›˜ç ';
@@ -97,6 +102,7 @@
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '编号',
    `uuid`        VARCHAR(32)  DEFAULT NULL COMMENT '生成组 UUID(标记同一次生成的数据包)',
    `code`        VARCHAR(64)  NOT NULL COMMENT '袋码(二维码内容,全局唯一)',
    `business_code` VARCHAR(16) DEFAULT NULL COMMENT '内部出入库业务码(16位数字)',
    `bag_no`      INT          NOT NULL COMMENT '全局袋序号',
    `batch_id`    BIGINT       DEFAULT NULL COMMENT '生产批次编号,录入信息后关联 mes_pro_batch.id',
    `batch_code`  VARCHAR(64)  DEFAULT NULL COMMENT '批次号(冗余,便于展示)',
@@ -115,6 +121,7 @@
    `deleted`     BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_code` (`code`),
    UNIQUE KEY `uk_business_code` (`business_code`),
    KEY `idx_batch_id` (`batch_id`),
    KEY `idx_uuid` (`uuid`),
    KEY `idx_pallet_id` (`pallet_id`)
sql/mysql/mes_pro_pallet_batch_business_code.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
-- æ‰˜ç›˜å’Œç”Ÿäº§æ‰¹æ¬¡ç‹¬ç«‹ä¸šåŠ¡ç ï¼›å…ˆå‡çº§æ•°æ®åº“å†éƒ¨ç½²åŽç«¯ã€‚åŽ†å²è®°å½•åœ¨é¡µé¢è¡¥ç”Ÿæˆã€‚
SET @business_exists = (SELECT COUNT(*) FROM information_schema.columns
 WHERE table_schema = DATABASE() AND table_name = 'mes_pro_pallet' AND column_name = 'business_code');
SET @business_ddl = IF(@business_exists = 0,
 'ALTER TABLE mes_pro_pallet ADD COLUMN business_code VARCHAR(16) DEFAULT NULL COMMENT ''内部出入库业务码(16位数字)'' AFTER code', 'SELECT 1');
PREPARE business_stmt FROM @business_ddl;
EXECUTE business_stmt;
DEALLOCATE PREPARE business_stmt;
SET @business_exists = (SELECT COUNT(*) FROM information_schema.statistics
 WHERE table_schema = DATABASE() AND table_name = 'mes_pro_pallet' AND index_name = 'uk_business_code');
SET @business_ddl = IF(@business_exists = 0,
 'ALTER TABLE mes_pro_pallet ADD UNIQUE KEY uk_business_code (business_code)', 'SELECT 1');
PREPARE business_stmt FROM @business_ddl;
EXECUTE business_stmt;
DEALLOCATE PREPARE business_stmt;
SET @business_exists = (SELECT COUNT(*) FROM information_schema.columns
 WHERE table_schema = DATABASE() AND table_name = 'mes_pro_batch' AND column_name = 'business_code');
SET @business_ddl = IF(@business_exists = 0,
 'ALTER TABLE mes_pro_batch ADD COLUMN business_code VARCHAR(16) DEFAULT NULL COMMENT ''内部出入库业务码(16位数字)'' AFTER code', 'SELECT 1');
PREPARE business_stmt FROM @business_ddl;
EXECUTE business_stmt;
DEALLOCATE PREPARE business_stmt;
SET @business_exists = (SELECT COUNT(*) FROM information_schema.statistics
 WHERE table_schema = DATABASE() AND table_name = 'mes_pro_batch' AND index_name = 'uk_business_code');
SET @business_ddl = IF(@business_exists = 0,
 'ALTER TABLE mes_pro_batch ADD UNIQUE KEY uk_business_code (business_code)', 'SELECT 1');
PREPARE business_stmt FROM @business_ddl;
EXECUTE business_stmt;
DEALLOCATE PREPARE business_stmt;
sql/mysql/mes_pro_pallet_item.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
-- æ‰˜ç›˜ä¿å­˜å“ç§ï¼›ä»…从有效袋码中唯一且与已有批次兼容的品种回填。
SET @pallet_item_exists = (SELECT COUNT(*) FROM information_schema.columns
 WHERE table_schema=DATABASE() AND table_name='mes_pro_pallet' AND column_name='item_id');
SET @pallet_item_ddl = IF(@pallet_item_exists=0,
 'ALTER TABLE mes_pro_pallet ADD COLUMN item_id BIGINT DEFAULT NULL COMMENT ''托盘品种编号'' AFTER temp_code', 'SELECT 1');
PREPARE pallet_item_stmt FROM @pallet_item_ddl;
EXECUTE pallet_item_stmt;
DEALLOCATE PREPARE pallet_item_stmt;
UPDATE mes_pro_pallet p
JOIN (SELECT pallet_id, MIN(item_id) item_id FROM mes_pro_bag_code
 WHERE deleted=0 AND status<>30 AND pallet_id IS NOT NULL GROUP BY pallet_id
 HAVING COUNT(DISTINCT item_id)=1 AND COUNT(*)=COUNT(item_id)) b ON b.pallet_id=p.id
LEFT JOIN mes_pro_batch ba ON ba.id=p.batch_id AND ba.deleted=0
SET p.item_id=b.item_id
WHERE p.deleted=0 AND p.item_id IS NULL AND (ba.item_id IS NULL OR ba.item_id=b.item_id);
UPDATE mes_pro_batch ba
JOIN (SELECT batch_id,MIN(item_id) item_id FROM mes_pro_bag_code
 WHERE deleted=0 AND status<>30 AND batch_id IS NOT NULL GROUP BY batch_id
 HAVING COUNT(DISTINCT item_id)=1 AND COUNT(*)=COUNT(item_id)) b ON b.batch_id=ba.id
SET ba.item_id=b.item_id
WHERE ba.deleted=0 AND ba.item_id IS NULL
 AND NOT EXISTS (SELECT 1 FROM mes_trace_integrity_record r WHERE r.biz_type='BATCH' AND r.biz_id=ba.id AND r.deleted=0)
 AND NOT EXISTS (SELECT 1 FROM mes_pro_pallet p WHERE p.batch_id=ba.id AND p.deleted=0 AND p.item_id IS NOT NULL AND p.item_id<>b.item_id);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java
@@ -142,12 +142,12 @@
    }
    @GetMapping("/get-by-code")
    @Operation(summary = "扫码追溯:根据袋码(二维码内容)反查批次、产线、责任人等信息")
    @Operation(summary = "内部扫码查询:根据追溯码或业务码反查袋、批次、产线信息")
    @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);
        MesProBagCodeDO bagCode = bagCodeService.getBagCodeByScanCode(code);
        if (bagCode == null) {
            throw exception(PRO_BAG_CODE_NOT_EXISTS);
        }
@@ -219,6 +219,31 @@
    // ==================== æ‹¼æŽ¥ VO ====================
    @PostMapping("/generate-business-code")
    @Operation(summary = "补生成已有袋码的出入库业务码(已有码值不变)")
    @PreAuthorize("@ss.hasPermission('mes:pro-bag-code:create')")
    public CommonResult<Integer> generateBusinessCodes(@Valid @RequestBody
            cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagBusinessCodeGenerateReqVO reqVO) {
        return success(bagCodeService.generateBusinessCodes(reqVO.getIds()));
    }
    @GetMapping("/export-business-qr")
    @Operation(summary = "导出出入库业务码二维码 ZIP(二维码仅编码业务码值)")
    @PreAuthorize("@ss.hasPermission('mes:pro-bag-code:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportBusinessQr(@RequestParam(value = "uuid", required = false) String uuid,
                                 @RequestParam(value = "batchId", required = false) Long batchId,
                                 HttpServletResponse response) throws IOException {
        List<MesProBagCodeDO> list;
        if (StrUtil.isNotBlank(uuid)) list = bagCodeService.getPrintableBagCodeListByUuid(uuid);
        else if (batchId != null) list = bagCodeService.getPrintableBagCodeListByBatchId(batchId);
        else throw exception(PRO_QR_EXPORT_PARAMS_INVALID);
        if (CollUtil.isEmpty(list)) throw exception(PRO_BAG_CODE_NOT_EXISTS);
        if (list.size() > QrCodeImageUtils.MAX_QR_EXPORT) throw exception(PRO_QR_EXPORT_TOO_MANY);
        List<QrLabelInfo> labels = list.stream().map(MesProQrLabelBuilder::buildBagBusinessCodeLabel).toList();
        QrCodeImageUtils.writeZip(response, "袋码业务二维码-" + (StrUtil.isNotBlank(uuid) ? uuid : batchId), labels);
    }
    private List<MesProBagCodeRespVO> buildBagCodeRespVOList(List<MesProBagCodeDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagBusinessCodeGenerateReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "管理后台 - å·²æœ‰è¢‹ç è¡¥ç”Ÿæˆä¸šåŠ¡ç ")
public class MesProBagBusinessCodeGenerateReqVO {
    @NotEmpty
    @Size(max = 200)
    private List<@NotNull Long> ids;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodePageReqVO.java
@@ -20,6 +20,9 @@
    @Schema(description = "袋码", example = "PC20260818L01A001-0001")
    private String code;
    @Schema(description = "出入库业务码")
    private String businessCode;
    @Schema(description = "生产批次编号", example = "1024")
    private Long batchId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeRespVO.java
@@ -14,6 +14,8 @@
@Schema(description = "管理后台 - MES è¢‹ç èµ‹ç  Response VO")
@Data
public class MesProBagCodeRespVO {
    @ExcelProperty("入库时间")
    private LocalDateTime inboundTime;
    @Schema(description = "编号", example = "1024")
    @ExcelIgnore
@@ -24,10 +26,15 @@
    private String uuid;
    @Schema(description = "袋码(二维码内容)", example = "PC20260818L01001-0001")
    @ExcelProperty("二维码内容")
    @ExcelProperty("追溯码")
    @ColumnWidth(30)
    private String code;
    @Schema(description = "出入库业务码(二维码编码纯码值)")
    @ExcelProperty("业务码")
    @ColumnWidth(24)
    private String businessCode;
    @Schema(description = "批次内袋序号", example = "1")
    @ExcelProperty("袋序号")
    private Integer bagNo;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeTraceRespVO.java
@@ -8,6 +8,7 @@
@Schema(description = "管理后台 - MES è¢‹ç æ‰«ç è¿½æº¯ Response VO")
@Data
public class MesProBagCodeTraceRespVO {
    private LocalDateTime inboundTime;
    // ========== è¢‹ç ä¿¡æ¯ ==========
@@ -17,6 +18,9 @@
    @Schema(description = "袋码(二维码内容)", example = "PC20260818L01001-0001")
    private String code;
    @Schema(description = "内部出入库业务码")
    private String businessCode;
    @Schema(description = "批次内袋序号", example = "1")
    private Integer bagNo;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/MesProBatchController.java
@@ -181,7 +181,7 @@
    @Parameter(name = "code", description = "批次号(二维码内容)", required = true, example = "PC20260818L01001")
    @PreAuthorize("@ss.hasPermission('mes:pro-batch:query')")
    public CommonResult<MesProBatchTraceRespVO> getBatchByCode(@RequestParam("code") String code) {
        MesProBatchDO batch = batchService.getBatchByCode(code);
        MesProBatchDO batch = batchService.getBatchByScanCode(code);
        if (batch == null) {
            throw exception(PRO_BATCH_NOT_EXISTS);
        }
@@ -209,6 +209,33 @@
    // ==================== æ‹¼æŽ¥ VO ====================
    @PostMapping("/generate-business-code")
    @Operation(summary = "补生成已有生产批次业务码,已有码值不变")
    @PreAuthorize("@ss.hasPermission('mes:pro-batch:create')")
    public CommonResult<Integer> generateBusinessCodes(@Valid @RequestBody
            cn.iocoder.yudao.module.mes.controller.admin.pro.vo.MesProBusinessCodeGenerateReqVO reqVO) {
        return success(batchService.generateBusinessCodes(reqVO.getIds()));
    }
    @GetMapping("/export-business-qr")
    @Operation(summary = "导出批次及所属托盘业务二维码,内容为纯业务码值")
    @PreAuthorize("@ss.hasPermission('mes:pro-batch:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportBusinessQr(@RequestParam("batchId") Long batchId,
                                  HttpServletResponse response) throws IOException {
        MesProBatchDO batch = batchService.getBatch(batchId);
        if (batch == null) throw exception(PRO_BATCH_NOT_EXISTS);
        if (cn.iocoder.yudao.module.mes.enums.pro.MesProBatchStatusEnum.CANCELED.getStatus().equals(batch.getStatus()))
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "已作废批次不可导出业务二维码");
        List<MesProPalletDO> pallets = palletService.getPalletListByBatchId(batchId).stream()
                .filter(record -> !cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum.CANCELED.getStatus().equals(record.getStatus())).toList();
        if (pallets.size() + 1 > QrCodeImageUtils.MAX_QR_EXPORT) throw exception(PRO_QR_EXPORT_TOO_MANY);
        List<QrLabelInfo> labels = new java.util.ArrayList<>();
        labels.add(MesProQrLabelBuilder.buildBatchBusinessCodeLabel(batch));
        pallets.forEach(pallet -> labels.add(MesProQrLabelBuilder.buildPalletBusinessCodeLabel(pallet)));
        QrCodeImageUtils.writeZip(response, "批次业务二维码-" + batch.getBusinessCode(), labels);
    }
    private List<MesProBatchRespVO> buildBatchRespVOList(List<MesProBatchDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
@@ -216,10 +243,11 @@
        // 1. èŽ·å¾—å…³è”æ•°æ®ï¼ˆè´£ä»»äººï¼‰ï¼›å“ç§å·²ä¸‹æ²‰åˆ°è¢‹ç æ•°æ®åŒ…ï¼Œæ‰¹æ¬¡åˆ—è¡¨ä¸å†å±•ç¤º
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(list, MesProBatchDO::getOwnerUserId));
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesProBatchRespVO.class, vo ->
                MapUtils.findAndThen(userMap, vo.getOwnerUserId(), user ->
                        vo.setOwnerUserName(user.getNickname())));
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(convertSet(list, MesProBatchDO::getItemId));
        return BeanUtils.toBean(list, MesProBatchRespVO.class, vo -> {
            MapUtils.findAndThen(userMap, vo.getOwnerUserId(), user -> vo.setOwnerUserName(user.getNickname()));
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> { vo.setItemCode(item.getCode()); vo.setItemName(item.getName()); });
        });
    }
    private List<MesProBatchWorkerRespVO> buildWorkerRespVOList(List<MesProBatchWorkerDO> workers) {
@@ -256,6 +284,7 @@
        respVO.setPallets(BeanUtils.toBean(palletService.getPalletListByBatchId(batch.getId()), MesProPalletRespVO.class));
        // è¢‹ç åˆ—表(封顶 2000,bagTotal è®°å½•总数)
        List<MesProBagCodeDO> bags = bagCodeService.getBagCodeListByBatchId(batch.getId());
        respVO.setPendingBagQuantity((int) bags.stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus()) && b.getInboundEventId() == null).count());
        respVO.setBagTotal(bags.size());
        if (bags.size() > QrCodeImageUtils.MAX_QR_EXPORT) {
            bags = bags.subList(0, QrCodeImageUtils.MAX_QR_EXPORT);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchPageReqVO.java
@@ -19,6 +19,8 @@
    @Schema(description = "批次号", example = "PC20260818L01NY001")
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
    @Schema(description = "临时批次码", example = "PB-00000001")
    private String tempCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchPreGenerateRespVO.java
@@ -12,5 +12,7 @@
    @Schema(description = "临时批次码(二维码内容)", example = "PB-00000001")
    private String tempCode;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchRespVO.java
@@ -25,10 +25,19 @@
    @ExcelProperty("批次号")
    @ColumnWidth(25)
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    @ExcelProperty("业务码")
    private String businessCode;
    @Schema(description = "临时批次码(预生成二维码阶段使用;补录正式信息后为空或保留)", example = "PB-00000001")
    @ExcelIgnore
    private String tempCode;
    @ExcelIgnore
    private Long itemId;
    @ExcelProperty("品种编码")
    private String itemCode;
    @ExcelProperty("品种名称")
    private String itemName;
    @Schema(description = "产线编号", example = "1")
    @ExcelIgnore
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchSaveReqVO.java
@@ -20,6 +20,8 @@
    @Schema(description = "编号", example = "1024")
    private Long id;
    @Schema(description = "品种编号,由绑定托盘带入并经后端校验")
    private Long itemId;
    @Schema(description = "产线编号(页面展示为「加工单位」)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "加工单位不能为空")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchTraceRespVO.java
@@ -11,6 +11,8 @@
@Schema(description = "管理后台 - MES ç”Ÿäº§æ‰¹æ¬¡æ‰«ç è¿½æº¯ Response VO")
@Data
public class MesProBatchTraceRespVO {
    private Integer inboundBagQuantity;
    private Integer pendingBagQuantity;
    // ========== æ‰¹æ¬¡ä¿¡æ¯ ==========
@@ -19,6 +21,8 @@
    @Schema(description = "批次号(补录后的正式批次号)", example = "PC20260818L01001")
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
    @Schema(description = "临时批次码(二维码内容;未补录信息时展示)", example = "PB-00000001")
    private String tempCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/MesProPalletController.java
@@ -127,7 +127,7 @@
    @PreAuthorize("@ss.hasPermission('mes:pro-pallet:query')")
    public CommonResult<MesProPalletRespVO> getPallet(@RequestParam("id") Long id) {
        MesProPalletDO pallet = palletService.getPallet(id);
        return success(BeanUtils.toBean(pallet, MesProPalletRespVO.class));
        return success(pallet == null ? null : buildPalletRespVOList(List.of(pallet)).get(0));
    }
    @GetMapping("/get-by-code")
@@ -135,7 +135,7 @@
    @Parameter(name = "code", description = "托盘码(二维码内容)", required = true, example = "PC20260818L01001-P01")
    @PreAuthorize("@ss.hasPermission('mes:pro-pallet:query')")
    public CommonResult<MesProPalletTraceRespVO> getPalletByCode(@RequestParam("code") String code) {
        MesProPalletDO pallet = palletService.getPalletByCode(code);
        MesProPalletDO pallet = palletService.getPalletByScanCode(code);
        if (pallet == null) {
            throw exception(PRO_PALLET_NOT_EXISTS);
        }
@@ -147,7 +147,7 @@
    @PreAuthorize("@ss.hasPermission('mes:pro-pallet:query')")
    public CommonResult<PageResult<MesProPalletRespVO>> getPalletPage(@Valid MesProPalletPageReqVO pageReqVO) {
        PageResult<MesProPalletDO> pageResult = palletService.getPalletPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, MesProPalletRespVO.class));
        return success(new PageResult<>(buildPalletRespVOList(pageResult.getList()), pageResult.getTotal()));
    }
    @GetMapping("/simple-list")
@@ -156,7 +156,7 @@
    @PreAuthorize("@ss.hasPermission('mes:pro-pallet:query')")
    public CommonResult<List<MesProPalletRespVO>> getPalletSimpleList(@RequestParam("batchId") Long batchId) {
        List<MesProPalletDO> list = palletService.getPalletListByBatchId(batchId);
        return success(BeanUtils.toBean(list, MesProPalletRespVO.class));
        return success(buildPalletRespVOList(list));
    }
    @GetMapping("/unbound-list")
@@ -164,7 +164,7 @@
    @PreAuthorize("@ss.hasPermission('mes:pro-pallet:query')")
    public CommonResult<List<MesProPalletRespVO>> getUnboundPalletList() {
        List<MesProPalletDO> list = palletService.getUnboundPalletList();
        return success(BeanUtils.toBean(list, MesProPalletRespVO.class));
        return success(buildPalletRespVOList(list));
    }
    @GetMapping("/export-excel")
@@ -175,7 +175,7 @@
                                  HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
        List<MesProPalletDO> list = palletService.getPalletPage(pageReqVO).getList();
        List<MesProPalletRespVO> voList = BeanUtils.toBean(list, MesProPalletRespVO.class);
        List<MesProPalletRespVO> voList = buildPalletRespVOList(list);
        ExcelUtils.write(response, "托盘码.xls", "数据", MesProPalletRespVO.class, voList);
    }
@@ -198,8 +198,42 @@
    // ==================== æ‹¼æŽ¥ VO ====================
    @PostMapping("/generate-business-code")
    @Operation(summary = "补生成已有托盘业务码,已有码值不变")
    @PreAuthorize("@ss.hasPermission('mes:pro-pallet:create')")
    public CommonResult<Integer> generateBusinessCodes(@Valid @RequestBody
            cn.iocoder.yudao.module.mes.controller.admin.pro.vo.MesProBusinessCodeGenerateReqVO reqVO) {
        return success(palletService.generateBusinessCodes(reqVO.getIds()));
    }
    @PostMapping("/export-business-qr-by-ids")
    @Operation(summary = "按编号导出托盘业务码二维码,内容为纯业务码值")
    @PreAuthorize("@ss.hasPermission('mes:pro-pallet:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportBusinessQrByIds(@Valid @RequestBody MesProPalletExportQrReqVO reqVO,
                                      HttpServletResponse response) throws IOException {
        List<MesProPalletDO> list = palletService.getPalletListByIds(reqVO.getIds()).stream()
                .filter(record -> !cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum.CANCELED.getStatus().equals(record.getStatus())).toList();
        if (CollUtil.isEmpty(list)) throw exception(PRO_PALLET_NOT_EXISTS);
        if (list.size() > QrCodeImageUtils.MAX_QR_EXPORT) throw exception(PRO_QR_EXPORT_TOO_MANY);
        QrCodeImageUtils.writeZip(response, "托盘业务二维码", list.stream()
                .map(MesProQrLabelBuilder::buildPalletBusinessCodeLabel).toList());
    }
    private List<MesProPalletRespVO> buildPalletRespVOList(List<MesProPalletDO> list) {
        Map<Long, MesMdmItemDO> items = mdmItemService.getItemMap(convertSet(list, MesProPalletDO::getItemId));
        return BeanUtils.toBean(list, MesProPalletRespVO.class, vo ->
                MapUtils.findAndThen(items, vo.getItemId(), item -> { vo.setItemCode(item.getCode()); vo.setItemName(item.getName()); }));
    }
    private MesProPalletTraceRespVO buildPalletTraceRespVO(MesProPalletDO pallet) {
        MesProPalletTraceRespVO respVO = BeanUtils.toBean(pallet, MesProPalletTraceRespVO.class);
        respVO.setPendingBagQuantity((int) bagCodeService.getBagCodeListByPalletId(pallet.getId()).stream()
                .filter(b -> !Integer.valueOf(30).equals(b.getStatus()) && b.getInboundEventId() == null).count());
        if (pallet.getItemId() != null) {
            MesMdmItemDO item = mdmItemService.getItem(pallet.getItemId());
            if (item != null) respVO.setItemCode(item.getCode()).setItemName(item.getName()).setItemSpecification(item.getSpecification());
        }
        // æ‰¹æ¬¡ä¿¡æ¯ï¼ˆä¸´æ—¶æ‰˜ç›˜æœªç»‘定批次时为空批次)
        MesProBatchDO batch = pallet.getBatchId() == null ? null : batchService.getBatch(pallet.getBatchId());
        if (batch != null) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletPageReqVO.java
@@ -19,6 +19,8 @@
    @Schema(description = "托盘码", example = "PC20260818L01A001-P01")
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
    @Schema(description = "临时托盘码", example = "PL-00000001")
    private String tempCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletPreGenerateRespVO.java
@@ -12,5 +12,7 @@
    @Schema(description = "临时托盘码(二维码内容)", example = "PL-00000001")
    private String tempCode;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletRespVO.java
@@ -14,6 +14,8 @@
@Schema(description = "管理后台 - MES æ‰˜ç›˜ç  Response VO")
@Data
public class MesProPalletRespVO {
    @ExcelProperty("入库时间")
    private LocalDateTime inboundTime;
    @Schema(description = "编号", example = "1024")
    @ExcelIgnore
@@ -23,10 +25,19 @@
    @ExcelProperty("托盘码")
    @ColumnWidth(30)
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    @ExcelProperty("业务码")
    private String businessCode;
    @Schema(description = "临时托盘码(预生成二维码阶段使用;绑定批次后为空或保留)", example = "PL-00000001")
    @ExcelIgnore
    private String tempCode;
    @ExcelIgnore
    private Long itemId;
    @ExcelProperty("品种编码")
    private String itemCode;
    @ExcelProperty("品种名称")
    private String itemName;
    @Schema(description = "生产批次编号", example = "1024")
    @ExcelIgnore
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/pallet/vo/MesProPalletTraceRespVO.java
@@ -10,6 +10,8 @@
@Schema(description = "管理后台 - MES æ‰˜ç›˜ç æ‰«ç è¿½æº¯ Response VO")
@Data
public class MesProPalletTraceRespVO {
    private LocalDateTime inboundTime;
    private Integer pendingBagQuantity;
    // ========== æ‰˜ç›˜ä¿¡æ¯ ==========
@@ -18,6 +20,8 @@
    @Schema(description = "托盘码(绑定批次后的正式托盘码)", example = "PC20260818L01001-P01")
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
    @Schema(description = "临时托盘码(二维码内容;未绑定批次时展示)", example = "PL-00000001")
    private String tempCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/MesProScanEventController.java
@@ -32,6 +32,20 @@
        PageResult<MesProScanEventDO> page = service.getPage(req);
        return success(new PageResult<>(BeanUtils.toBean(page.getList(), MesProScanEventRespVO.class), page.getTotal()));
    }
    @GetMapping("/my-page") @PreAuthorize("@ss.hasPermission('mes:pro-scan-in:query')")
    @Operation(summary = "本人扫码作业记录")
    public CommonResult<PageResult<MesProScanEventRespVO>> myPage(@Valid MesProScanEventPageReqVO req) {
        req.setCreator(String.valueOf(cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId()));
        return page(req);
    }
    @GetMapping("/my-event") @PreAuthorize("@ss.hasAnyPermissions('mes:pro-scan-in:query','mes:pro-scan-in:inbound')")
    @Operation(summary = "核实本人扫码操作结果")
    public CommonResult<MesProScanEventRespVO> myEvent(@RequestParam String eventId) {
        MesProScanEventDO event = service.getByEventId(eventId);
        String userId = String.valueOf(cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId());
        return success(event != null && userId.equals(event.getCreator())
                ? BeanUtils.toBean(event, MesProScanEventRespVO.class) : null);
    }
    @GetMapping("/latest") @PreAuthorize("@ss.hasPermission('mes:pro-scan-in:query')") @Operation(summary = "最新扫码事件")
    public CommonResult<MesProScanEventRespVO> latest(@RequestParam(required = false) String deviceCode) {
        return success(BeanUtils.toBean(service.getLatest(deviceCode), MesProScanEventRespVO.class));
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/vo/MesProScanEventPageReqVO.java
@@ -13,4 +13,8 @@
    private String deviceCode;
    private String status;
    private String errorCode;
    // The mobile endpoint always overrides this with the authenticated user.
    private String creator;
    @org.springframework.format.annotation.DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private java.time.LocalDateTime[] receivedTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/trace/MesProTraceResolveController.java
@@ -36,15 +36,15 @@
    @GetMapping("/resolve-type")
    @Operation(summary = "根据编码解析溯源类型:bag / pallet / batch;均不匹配返回空")
    @Parameter(name = "code", description = "编码(二维码内容)", required = true)
    @PreAuthorize("@ss.hasPermission('mes:pro-bag-code:query')")
    @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.getBagCodeByCode(code) != null) {
        if (bagCodeService.getBagCodeByScanCode(code) != null) {
            return success("bag");
        }
        if (palletService.getPalletByCode(code) != null) {
        if (palletService.getPalletByScanCode(code) != null) {
            return success("pallet");
        }
        if (batchService.getBatchByCode(code) != null) {
        if (batchService.getBatchByScanCode(code) != null) {
            return success("batch");
        }
        return success(null);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/vo/MesProBusinessCodeGenerateReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.vo;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Data
public class MesProBusinessCodeGenerateReqVO {
    @NotEmpty @Size(max = 200)
    private List<@NotNull Long> ids;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/MesQcSeedQualityController.java
@@ -20,6 +20,12 @@
@RestController @RequestMapping("/mes/qc/seed-quality") @Validated
public class MesQcSeedQualityController {
    @Resource private MesQcSeedQualityService service;
    @GetMapping("/resolve")
    @Operation(summary = "解析待质检袋码及确认该袋质检结果")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:submit')")
    public CommonResult<java.util.Map<String, Object>> resolve(@RequestParam String code, @RequestParam(defaultValue = "BAG") String scanType) {
        return success(service.resolveScan(code, scanType));
    }
    @GetMapping("/page")
    @Operation(summary = "获得种子质检记录分页")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:query')")
@@ -30,4 +36,10 @@
    @PostMapping("/submit") @Operation(summary = "提交种子质检")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:submit')")
    public CommonResult<Long> submit(@Valid @RequestBody MesQcSeedQualitySubmitReqVO reqVO) { return success(service.submit(reqVO)); }
    @PostMapping("/submit-group") @Operation(summary = "按袋码、托盘或生产批次统一提交种子质检")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:submit')")
    public CommonResult<java.util.Map<String, Object>> submitGroup(@Valid @RequestBody cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO reqVO) {
        return success(service.submitGroup(reqVO));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityGroupSubmitReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class MesQcSeedQualityGroupSubmitReqVO extends MesQcSeedQualitySubmitReqVO {
    @NotBlank @Pattern(regexp = "BAG|PALLET|BATCH")
    private String scanType;
    /** æ“ä½œå‘˜æ ¸å¯¹è¿‡çš„待检袋清单,提交时锁定并重新核实,禁止悄悄扩大检验范围。 */
    @NotEmpty
    private List<@NotNull Long> bagIds;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesController.java
@@ -136,6 +136,14 @@
        return success(productSalesService.scanProductSales(reqVO));
    }
    @GetMapping("/scan-preview")
    @Operation(summary = "核对扫码拣货对象(不写入明细)")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:stock')")
    public CommonResult<MesWmProductSalesScanRespVO> previewScan(@RequestParam String code,
            @RequestParam(required = false) String scanType, @RequestParam(required = false) Long salesId) {
        return success(productSalesService.previewScan(code, scanType, salesId));
    }
    @PutMapping("/stock")
    @Operation(summary = "执行拣货")
    @Parameter(name = "id", description = "编号", required = true)
@@ -198,4 +206,4 @@
        });
    }
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanReqVO.java
@@ -6,6 +6,7 @@
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Schema(description = "管理后台 - MES é”€å”®å‡ºåº“扫码拣货 Request VO")
@Data
@@ -15,11 +16,17 @@
    @NotNull(message = "销售出库单ID不能为空")
    private Long salesId;
    @Schema(description = "扫描内容(袋码或托盘码)", requiredMode = Schema.RequiredMode.REQUIRED, example = "PC2026081920-0001")
    @Schema(description = "扫描内容(袋码、托盘码或生产批次码)", requiredMode = Schema.RequiredMode.REQUIRED, example = "PC2026081920-0001")
    @NotEmpty(message = "扫描内容不能为空")
    private String scanContent;
    @Schema(description = "本次出库数量(为空则取该袋码/整托全部数量)", example = "50")
    @Schema(description = "本次拣货袋数,必须与所选袋码数量相同", example = "50")
    private BigDecimal quantity;
}
    @jakarta.validation.constraints.Pattern(regexp = "BAG|PALLET|BATCH", message = "扫码类型无效")
    private String scanType;
    @Schema(description = "本次明确选中的袋码 ID;托盘和批次必须选择")
    private List<@NotNull Long> bagIds;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanRespVO.java
@@ -4,12 +4,13 @@
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Schema(description = "管理后台 - MES é”€å”®å‡ºåº“扫码拣货 Response VO")
@Data
public class MesWmProductSalesScanRespVO {
    @Schema(description = "识别类型:PALLET-托盘码,BAG-袋码", example = "BAG")
    @Schema(description = "识别类型:PALLET-托盘码,BAG-袋码,BATCH-生产批次码", example = "BAG")
    private String scanType;
    @Schema(description = "扫描的码编号", example = "12")
@@ -72,4 +73,17 @@
    @Schema(description = "操作信息提示", example = "扫码成功")
    private String message;
}
    private List<BagOption> bags;
    private List<Long> selectedBagIds;
    @Data
    public static class BagOption {
        private Long id;
        private String code;
        private String businessCode;
        private Long palletId;
        private Boolean selectable;
        private String unavailableReason;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/detail/MesWmProductSalesDetailRespVO.java
@@ -37,6 +37,12 @@
    @Schema(description = "拣货数量", example = "50")
    private BigDecimal quantity;
    @Schema(description = "追溯袋码 ID,每袋明细保留实际袋码关联")
    private Long bagCodeId;
    @Schema(description = "所属托盘码 ID,部分拣货时仅表示袋码来源")
    private Long palletCodeId;
    @Schema(description = "库存记录ID", example = "1")
    private Long materialStockId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/bagcode/MesProBagCodeDO.java
@@ -23,6 +23,8 @@
    private Long id;
    private String uuid;
    private String code;
    /** å†…部出入库业务码,独立于对外追溯码,二维码编码纯码值。 */
    private String businessCode;
    private Integer bagNo;
    private Long batchId;
    private String batchCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/batch/MesProBatchDO.java
@@ -39,6 +39,8 @@
     * æ‰¹æ¬¡å·ï¼ˆè‡ªåŠ¨ç”Ÿæˆï¼šå‰ç¼€ + æ—¥æœŸ + äº§çº¿ç¼–码 + æµæ°´å·ï¼›è¡¥å½•业务信息后生成)
     */
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
    /**
     * ä¸´æ—¶æ‰¹æ¬¡ç ï¼ˆç å€¼ï¼šPB-8位全局序号,预生成二维码阶段使用;补录正式信息后仍可扫码追溯)
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/pallet/MesProPalletDO.java
@@ -35,10 +35,14 @@
     * æ‰˜ç›˜ç ï¼ˆæ‰¹æ¬¡å·-P+托盘序号,绑定批次后生成)
     */
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
    private String businessCode;
    /**
     * ä¸´æ—¶æ‰˜ç›˜ç ï¼ˆç å€¼ï¼šPL-8位全局序号,预生成二维码阶段使用;绑定批次后仍可扫码追溯)
     */
    private String tempCode;
    /** æ‰˜ç›˜å“ç§ç¼–号,生成或首次装袋时带入。 */
    private Long itemId;
    /**
     * ç”Ÿäº§æ‰¹æ¬¡ç¼–号
     * <p>
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java
@@ -28,6 +28,18 @@
        return selectOne(MesProBagCodeDO::getCode, code);
    }
    /** å†…部扫码兼容业务码与历史追溯码;公众追溯继续只按 code æŸ¥è¯¢ã€‚ */
    default MesProBagCodeDO selectByScanCode(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .and(q -> q.eq(MesProBagCodeDO::getBusinessCode, code).or().eq(MesProBagCodeDO::getCode, code)));
    }
    default MesProBagCodeDO selectByScanCodeForUpdate(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .and(q -> q.eq(MesProBagCodeDO::getBusinessCode, code).or().eq(MesProBagCodeDO::getCode, code))
                .last("FOR UPDATE"));
    }
    default MesProBagCodeDO selectByCodeForUpdate(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .eq(MesProBagCodeDO::getCode, code).last("FOR UPDATE"));
@@ -39,13 +51,13 @@
    /** æ‰¹æ¬¡æ•´æ‰¹å…¥åº“时批量标记其下未入库袋码(幂等),保持袋码与库存状态一致 */
    @Update("UPDATE mes_pro_bag_code SET inbound_time = NOW(), inbound_event_id = #{eventId}, inbound_line_code = #{lineCode}, update_time = NOW() "
            + "WHERE deleted = 0 AND inbound_event_id IS NULL AND batch_id = #{batchId}")
            + "WHERE deleted = 0 AND status <> 30 AND inbound_event_id IS NULL AND batch_id = #{batchId}")
    int markInboundByBatchId(@Param("batchId") Long batchId, @Param("eventId") Long eventId,
                             @Param("lineCode") String lineCode);
    /** æ•´æ‰˜å…¥åº“时批量标记托盘下未入库袋码(幂等) */
    @Update("UPDATE mes_pro_bag_code SET inbound_time = NOW(), inbound_event_id = #{eventId}, inbound_line_code = #{lineCode}, update_time = NOW() "
            + "WHERE deleted = 0 AND inbound_event_id IS NULL AND pallet_id = #{palletId}")
            + "WHERE deleted = 0 AND status <> 30 AND inbound_event_id IS NULL AND pallet_id = #{palletId}")
    int markInboundByPalletId(@Param("palletId") Long palletId, @Param("eventId") Long eventId,
                              @Param("lineCode") String lineCode);
@@ -107,10 +119,16 @@
    default List<MesProBagCodeDO> selectListByPalletId(Long palletId) {
        return selectList(MesProBagCodeDO::getPalletId, palletId);
    }
    default List<MesProBagCodeDO> selectListByPalletIdForUpdate(Long palletId) {
        return selectList(new LambdaQueryWrapperX<MesProBagCodeDO>().eq(MesProBagCodeDO::getPalletId, palletId).orderByAsc(MesProBagCodeDO::getId).last("FOR UPDATE"));
    }
    default List<MesProBagCodeDO> selectListByBatchIdForUpdate(Long batchId) {
        return selectList(new LambdaQueryWrapperX<MesProBagCodeDO>().eq(MesProBagCodeDO::getBatchId, batchId).orderByAsc(MesProBagCodeDO::getId).last("FOR UPDATE"));
    }
    default PageResult<MesProBagCodeDO> selectPage(MesProBagCodePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesProBagCodeDO>()
                .likeIfPresent(MesProBagCodeDO::getCode, reqVO.getCode())
                .likeIfPresent(MesProBagCodeDO::getBusinessCode, reqVO.getBusinessCode())
                .eqIfPresent(MesProBagCodeDO::getBatchId, reqVO.getBatchId())
                .likeIfPresent(MesProBagCodeDO::getBatchCode, reqVO.getBatchCode())
                .eqIfPresent(MesProBagCodeDO::getUuid, reqVO.getUuid())
@@ -119,7 +137,10 @@
                .eqIfPresent(MesProBagCodeDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(MesProBagCodeDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(MesProBagCodeDO::getCreateTime)
                .orderByDesc(MesProBagCodeDO::getId));
                .orderByDesc(MesProBagCodeDO::getId)
                .and(cn.hutool.core.util.StrUtil.isNotBlank(reqVO.getCode()), q ->
                        q.like(MesProBagCodeDO::getCode, reqVO.getCode()).or()
                                .like(MesProBagCodeDO::getBusinessCode, reqVO.getCode())));
    }
    /**
@@ -219,6 +240,14 @@
            "AND deleted = 0 AND status <> 30")
    int selectEffectiveCountByBatchId(@Param("batchId") Long batchId);
    @Select("SELECT COUNT(*) FROM mes_pro_bag_code WHERE deleted = 0 AND status <> 30 "
            + "AND inbound_event_id IS NULL AND batch_id = #{batchId}")
    int selectPendingInboundCountByBatchId(@Param("batchId") Long batchId);
    @Select("SELECT COUNT(*) FROM mes_pro_bag_code WHERE deleted = 0 AND status <> 30 "
            + "AND inbound_event_id IS NULL AND pallet_id = #{palletId}")
    int selectPendingInboundCountByPalletId(@Param("palletId") Long palletId);
    /**
     * æ‰¹é‡æ ‡è®°ä¸ºå·²å¯¼å‡ºå°åˆ·ï¼ˆä»…更新处于目标原状态的记录)
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/batch/MesProBatchMapper.java
@@ -20,14 +20,21 @@
        return selectOne(MesProBatchDO::getCode, code);
    }
    default MesProBatchDO selectByScanCode(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProBatchDO>().and(q ->
                q.eq(MesProBatchDO::getBusinessCode, code).or().eq(MesProBatchDO::getCode, code)
                        .or().eq(MesProBatchDO::getTempCode, code)));
    }
    default MesProBatchDO selectByScanCodeForUpdate(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProBatchDO>().and(q ->
                q.eq(MesProBatchDO::getBusinessCode, code).or().eq(MesProBatchDO::getCode, code)
                        .or().eq(MesProBatchDO::getTempCode, code)).last("FOR UPDATE"));
    }
    default MesProBatchDO selectByTempCode(String tempCode) {
        return selectOne(MesProBatchDO::getTempCode, tempCode);
    }
    /** ç”Ÿæˆä¸´æ—¶æ‰¹æ¬¡ç ç”¨çš„全局序号:解析 temp_code çš„æ•°å­—后缀,不存在则返回 0。
     *  ä¸èƒ½è¿‡æ»¤ deleted,唯一索引 uk_temp_code å¯¹é€»è¾‘删除行仍生效,忽略软删会导致序号回退、码值重复 */
    @Select("SELECT IFNULL(MAX(CAST(SUBSTRING(temp_code, 4) AS UNSIGNED)), 0) FROM mes_pro_batch")
    Integer selectMaxTempCodeSeq();
    default Long selectCountByLineId(Long lineId) {
        return selectCount(MesProBatchDO::getLineId, lineId);
@@ -46,9 +53,20 @@
    int markBatchInbound(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("eventId") Long eventId,
                         @org.apache.ibatis.annotations.Param("lineCode") String lineCode, @org.apache.ibatis.annotations.Param("bagQuantity") Integer bagQuantity);
    /** ä»¥æœ‰æ•ˆè¢‹ç çš„入库标记为准;混扫、重试和作废袋不影响完成判断。调用方已锁定批次。 */
    @org.apache.ibatis.annotations.Update("UPDATE mes_pro_batch SET inbound_time = NOW(), inbound_event_id = #{eventId}, "
            + "inbound_device_code = #{deviceCode}, inbound_line_code = #{lineCode}, "
            + "inbound_bag_quantity = (SELECT COUNT(*) FROM mes_pro_bag_code b WHERE b.batch_id = #{id} "
            + "AND b.deleted = 0 AND b.status <> 30 AND b.inbound_event_id IS NOT NULL), "
            + "status = CASE WHEN EXISTS (SELECT 1 FROM mes_pro_bag_code b WHERE b.batch_id = #{id} AND b.deleted = 0 AND b.status <> 30) "
            + "AND NOT EXISTS (SELECT 1 FROM mes_pro_bag_code b WHERE b.batch_id = #{id} AND b.deleted = 0 AND b.status <> 30 AND b.inbound_event_id IS NULL) "
            + "THEN 30 ELSE status END, update_time = NOW() WHERE id = #{id} AND deleted = 0 AND status <> 40")
    int refreshInboundProgress(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("eventId") Long eventId,
                               @org.apache.ibatis.annotations.Param("deviceCode") String deviceCode, @org.apache.ibatis.annotations.Param("lineCode") String lineCode);
    default PageResult<MesProBatchDO> selectPage(MesProBatchPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesProBatchDO>()
                .likeIfPresent(MesProBatchDO::getCode, reqVO.getCode())
                .likeIfPresent(MesProBatchDO::getBusinessCode, reqVO.getBusinessCode())
                .likeIfPresent(MesProBatchDO::getTempCode, reqVO.getTempCode())
                .eqIfPresent(MesProBatchDO::getLineId, reqVO.getLineId())
                .likeIfPresent(MesProBatchDO::getLineCode, reqVO.getLineCode())
@@ -58,7 +76,11 @@
                .eqIfPresent(MesProBatchDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(MesProBatchDO::getProduceDate, reqVO.getProduceDate())
                .betweenIfPresent(MesProBatchDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(MesProBatchDO::getId));
                .orderByDesc(MesProBatchDO::getId)
                .and(cn.hutool.core.util.StrUtil.isNotBlank(reqVO.getCode()), q ->
                        q.like(MesProBatchDO::getCode, reqVO.getCode()).or()
                                .like(MesProBatchDO::getBusinessCode, reqVO.getCode()).or()
                                .like(MesProBatchDO::getTempCode, reqVO.getCode())));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/pallet/MesProPalletMapper.java
@@ -24,14 +24,21 @@
        return selectOne(MesProPalletDO::getCode, code);
    }
    default MesProPalletDO selectByScanCode(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProPalletDO>().and(q ->
                q.eq(MesProPalletDO::getBusinessCode, code).or().eq(MesProPalletDO::getCode, code)
                        .or().eq(MesProPalletDO::getTempCode, code)));
    }
    default MesProPalletDO selectByScanCodeForUpdate(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProPalletDO>().and(q ->
                q.eq(MesProPalletDO::getBusinessCode, code).or().eq(MesProPalletDO::getCode, code)
                        .or().eq(MesProPalletDO::getTempCode, code)).last("FOR UPDATE"));
    }
    default MesProPalletDO selectByTempCode(String tempCode) {
        return selectOne(MesProPalletDO::getTempCode, tempCode);
    }
    /** ç”Ÿæˆä¸´æ—¶æ‰˜ç›˜ç ç”¨çš„全局序号:解析 temp_code çš„æ•°å­—后缀,不存在则返回 0。
     *  ä¸èƒ½è¿‡æ»¤ deleted,唯一索引 uk_temp_code å¯¹é€»è¾‘删除行仍生效,忽略软删会导致序号回退、码值重复 */
    @Select("SELECT IFNULL(MAX(CAST(SUBSTRING(temp_code, 4) AS UNSIGNED)), 0) FROM mes_pro_pallet")
    Integer selectMaxTempCodeSeq();
    @Select("SELECT * FROM mes_pro_pallet WHERE id = #{id} AND deleted = 0 FOR UPDATE")
    MesProPalletDO selectByIdForUpdate(@Param("id") Long id);
@@ -51,13 +58,17 @@
    default PageResult<MesProPalletDO> selectPage(MesProPalletPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesProPalletDO>()
                .likeIfPresent(MesProPalletDO::getCode, reqVO.getCode())
                .likeIfPresent(MesProPalletDO::getBusinessCode, reqVO.getBusinessCode())
                .likeIfPresent(MesProPalletDO::getTempCode, reqVO.getTempCode())
                .eqIfPresent(MesProPalletDO::getBatchId, reqVO.getBatchId())
                .likeIfPresent(MesProPalletDO::getBatchCode, reqVO.getBatchCode())
                .eqIfPresent(MesProPalletDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(MesProPalletDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(MesProPalletDO::getId));
                .orderByDesc(MesProPalletDO::getId)
                .and(cn.hutool.core.util.StrUtil.isNotBlank(reqVO.getCode()), q ->
                        q.like(MesProPalletDO::getCode, reqVO.getCode()).or()
                                .like(MesProPalletDO::getBusinessCode, reqVO.getCode()).or()
                                .like(MesProPalletDO::getTempCode, reqVO.getCode())));
    }
    /**
@@ -79,6 +90,13 @@
            + "WHERE id = #{id} AND deleted = 0 AND inbound_event_id IS NULL")
    int markInbound(@Param("id") Long id, @Param("eventId") Long eventId);
    /** å•袋、整托、整批入库后,只有有效袋码全部入库的托盘才同步标记已入库。 */
    @org.apache.ibatis.annotations.Update("UPDATE mes_pro_pallet p SET inbound_event_id = #{eventId}, inbound_time = NOW(), update_time = NOW() "
            + "WHERE p.batch_id = #{batchId} AND p.deleted = 0 AND p.status <> 20 AND p.inbound_event_id IS NULL "
            + "AND EXISTS (SELECT 1 FROM mes_pro_bag_code b WHERE b.pallet_id = p.id AND b.deleted = 0 AND b.status <> 30) "
            + "AND NOT EXISTS (SELECT 1 FROM mes_pro_bag_code b WHERE b.pallet_id = p.id AND b.deleted = 0 AND b.status <> 30 AND b.inbound_event_id IS NULL)")
    int markCompletedInboundByBatchId(@Param("batchId") Long batchId, @Param("eventId") Long eventId);
    @org.apache.ibatis.annotations.Update("UPDATE mes_pro_pallet SET sales_id = NULL, outbound_time = NULL, update_time = NOW() "
            + "WHERE id = #{id} AND deleted = 0")
    void clearOutbound(@Param("id") Long id);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/scan/MesProScanEventMapper.java
@@ -18,12 +18,14 @@
    default PageResult<MesProScanEventDO> selectPage(MesProScanEventPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesProScanEventDO>()
                .eqIfPresent(MesProScanEventDO::getEventId, reqVO.getEventId())
                .eqIfPresent(MesProScanEventDO::getCreator, reqVO.getCreator())
                .betweenIfPresent(MesProScanEventDO::getReceivedTime, reqVO.getReceivedTime())
                .likeIfPresent(MesProScanEventDO::getBagCode, reqVO.getBagCode())
                .likeIfPresent(MesProScanEventDO::getBatchCode, reqVO.getBatchCode())
                .eqIfPresent(MesProScanEventDO::getDeviceCode, reqVO.getDeviceCode())
                .eqIfPresent(MesProScanEventDO::getStatus, reqVO.getStatus())
                .eqIfPresent(MesProScanEventDO::getErrorCode, reqVO.getErrorCode())
                .orderByDesc(MesProScanEventDO::getEventTime));
                .orderByDesc(MesProScanEventDO::getReceivedTime, MesProScanEventDO::getId));
    }
    default MesProScanEventDO selectLatest(String deviceCode) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityMapper.java
@@ -23,6 +23,17 @@
    default MesQcSeedQualityDO selectByCode(String code) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>().eq(MesQcSeedQualityDO::getCode, code));
    }
    default List<MesQcSeedQualityDO> selectListByCodes(java.util.Collection<String> codes) {
        if (codes.isEmpty()) return List.of();
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>().in(MesQcSeedQualityDO::getCode, codes));
    }
    default List<MesQcSeedQualityDO> selectListByCodesForUpdate(java.util.Collection<String> codes) {
        if (codes.isEmpty()) return List.of();
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>().in(MesQcSeedQualityDO::getCode, codes).last("FOR UPDATE"));
    }
    default MesQcSeedQualityDO selectByCodeForUpdate(String code) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>().eq(MesQcSeedQualityDO::getCode, code).last("FOR UPDATE"));
    }
    default List<MesQcSeedQualityDO> selectListByBatchCode(String batchCode) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstockbag/MesWmMaterialStockBagMapper.java
@@ -13,6 +13,14 @@
@Mapper
public interface MesWmMaterialStockBagMapper extends BaseMapperX<MesWmMaterialStockBagDO> {
    default List<MesWmMaterialStockBagDO> selectByBagIds(Collection<Long> ids, boolean lock) {
        if (ids.isEmpty()) return List.of();
        var query = new LambdaQueryWrapperX<MesWmMaterialStockBagDO>()
                .in(MesWmMaterialStockBagDO::getBagCodeId, ids).orderByAsc(MesWmMaterialStockBagDO::getBagCodeId);
        if (lock) query.last("FOR UPDATE");
        return selectList(query);
    }
    default PageResult<MesWmMaterialStockBagDO> selectPage(MesWmMaterialStockBagPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesWmMaterialStockBagDO>()
                .eq(MesWmMaterialStockBagDO::getMaterialStockId, reqVO.getMaterialStockId())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesDetailMapper.java
@@ -14,6 +14,23 @@
@Mapper
public interface MesWmProductSalesDetailMapper extends BaseMapperX<MesWmProductSalesDetailDO> {
    // ä»…未取消且尚未完成的出库单占用袋码。旧整托明细也参与重叠判断。
    @org.apache.ibatis.annotations.Select("""
        SELECT d.* FROM mes_wm_product_sales_detail d
        JOIN mes_wm_product_sales s ON s.id = d.sales_id
        WHERE d.deleted = 0 AND s.deleted = 0 AND s.status IN (2, 10, 3)
          AND d.batch_code = #{batchCode}
        """)
    List<MesWmProductSalesDetailDO> selectPickingByBatchCode(String batchCode);
    @org.apache.ibatis.annotations.Select("""
        SELECT d.* FROM mes_wm_product_sales_detail d
        JOIN mes_wm_product_sales s ON s.id = d.sales_id
        WHERE d.deleted = 0 AND s.deleted = 0 AND s.status IN (2, 10, 3)
          AND d.batch_code = #{batchCode} FOR UPDATE
        """)
    List<MesWmProductSalesDetailDO> selectPickingByBatchCodeForUpdate(String batchCode);
    default List<MesWmProductSalesDetailDO> selectListByLineId(Long lineId) {
        return selectList(MesWmProductSalesDetailDO::getLineId, lineId);
    }
@@ -31,7 +48,7 @@
    default MesWmProductSalesDetailDO selectBySalesIdAndPalletCodeId(Long salesId, Long palletCodeId) {
        return selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesWmProductSalesDetailDO>()
                .eq(MesWmProductSalesDetailDO::getSalesId, salesId)
                .eq(MesWmProductSalesDetailDO::getPalletCodeId, palletCodeId));
                .eq(MesWmProductSalesDetailDO::getPalletCodeId, palletCodeId).orderByAsc(MesWmProductSalesDetailDO::getId).last("LIMIT 1"));
    }
    default void deleteByLineId(Long lineId) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesMapper.java
@@ -18,6 +18,10 @@
@Mapper
public interface MesWmProductSalesMapper extends BaseMapperX<MesWmProductSalesDO> {
    default MesWmProductSalesDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<MesWmProductSalesDO>().eq(MesWmProductSalesDO::getId, id).last("FOR UPDATE"));
    }
    default PageResult<MesWmProductSalesDO> selectPage(MesWmProductSalesPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .likeIfPresent(MesWmProductSalesDO::getCode, reqVO.getCode())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilder.java
@@ -44,6 +44,26 @@
    private MesProQrLabelBuilder() {
    }
    /** å†…部业务标签独立于公众追溯标签,不拼接 H5 ç½‘址。 */
    public static QrLabelInfo buildBagBusinessCodeLabel(MesProBagCodeDO bag) {
        return buildBusinessCodeLabel(bag.getBusinessCode());
    }
    public static QrLabelInfo buildPalletBusinessCodeLabel(MesProPalletDO pallet) {
        return buildBusinessCodeLabel(pallet.getBusinessCode());
    }
    public static QrLabelInfo buildBatchBusinessCodeLabel(MesProBatchDO batch) {
        return buildBusinessCodeLabel(batch.getBusinessCode());
    }
    private static QrLabelInfo buildBusinessCodeLabel(String code) {
        if (code == null || !code.matches("^[0-9]{16}$")) {
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "缺少有效业务码,请先补生成业务码");
        }
        return QrLabelInfo.builder().fileName(code).code(code).keyValues(new LinkedHashMap<>()).build();
    }
    /**
     * å•元识别代码格式:1~20 ä½æ•°å­—或字母数字组合(国标要求,不含连字符等符号)
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeService.java
@@ -100,6 +100,12 @@
     */
    MesProBagCodeDO getBagCodeByCode(String code);
    /** å†…部查询识别业务码及历史追溯码,不用于公众追溯入口。 */
    MesProBagCodeDO getBagCodeByScanCode(String code);
    /** è¡¥ç”ŸæˆåŽ†å²è¢‹ç ä¸šåŠ¡ç ï¼›å·²æœ‰ç ä¿æŒä¸å˜ï¼Œè¿”å›žæ–°ç”Ÿæˆæ•°é‡ã€‚ */
    int generateBusinessCodes(List<Long> ids);
    /**
     * æŸ¥è¯¢è¢‹ç æ›¿æ¢è®°å½•
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java
@@ -89,6 +89,8 @@
    @Resource
    private MesCodeRedisDAO codeRedisDao;
    @Resource
    private cn.iocoder.yudao.module.mes.service.trace.integrity.MesTraceIntegrityService integrityService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -122,8 +124,10 @@
            for (int i = 0; i < palletCount; i++) {
                pallets.add(new MesProPalletDO()
                        .setTempCode(codeRedisDao.generate())
                        .setBusinessCode(codeRedisDao.generate())
                        .setBagCount(perPallet)
                        .setAutoGenerated(true)
                        .setItemId(item.getId())
                        .setStatus(MesProPalletStatusEnum.IN_USE.getStatus()));
            }
            palletMapper.insertBatch(pallets);
@@ -137,6 +141,7 @@
            bagCodes.add(new MesProBagCodeDO()
                    .setUuid(uuid)
                    .setCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setBagNo(bagNo)
                    .setItemId(item.getId())
                    .setItemCode(item.getCode())
@@ -169,6 +174,7 @@
            bagCodes.add(new MesProBagCodeDO()
                    .setUuid(uuid)
                    .setCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setBagNo(bagNo)
                    .setItemId(item.getId())
                    .setItemCode(item.getCode())
@@ -238,20 +244,31 @@
        }
        // å½•入的袋码必须属于同一品种,且与批次已有袋码品种一致(历史空白袋码 item ä¸ºç©ºä¸å‚与校验)
        Set<Long> itemIds = new LinkedHashSet<>();
        if (batch.getItemId() != null) itemIds.add(batch.getItemId());
        bagCodes.stream().map(MesProBagCodeDO::getItemId).filter(Objects::nonNull).forEach(itemIds::add);
        itemIds.addAll(bagCodeMapper.selectDistinctItemIdsByBatchId(batch.getId()));
        for (Long palletId : bagCodes.stream().map(MesProBagCodeDO::getPalletId).filter(Objects::nonNull).distinct().sorted().toList()) {
            MesProPalletDO pallet = palletMapper.selectByIdForUpdate(palletId);
            if (pallet == null) throw exception(PRO_PALLET_NOT_EXISTS);
            if (pallet.getBatchId() != null && !pallet.getBatchId().equals(batch.getId()))
                throw exception(PRO_BAG_CODE_BIND_PALLET_MISMATCH);
            Long palletItem = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.resolve(pallet, bagCodeMapper.selectListByPalletId(palletId));
            if (palletItem != null) itemIds.add(palletItem);
        }
        if (itemIds.size() > 1) {
            throw exception(PRO_BAG_CODE_BIND_BATCH_ITEM_MISMATCH);
        }
        if (!itemIds.isEmpty()) batch.setItemId(itemIds.iterator().next());
        if (bagCodeMapper.bindBatchByIds(ids, batch.getId(), batch.getCode(),
                List.of(MesProBagCodeStatusEnum.GENERATED.getStatus(),
                        MesProBagCodeStatusEnum.PRINTED.getStatus())) != ids.size()) {
            throw exception(PRO_BAG_CODE_STATUS_NOT_GENERATED);
        }
        batchMapper.updateById(new MesProBatchDO().setId(batch.getId())
        batchMapper.updateById(new MesProBatchDO().setId(batch.getId()).setItemId(batch.getItemId())
                .setBagQuantity(bagCodeMapper.selectEffectiveCountByBatchId(batch.getId())));
        // çº§è”:本组袋码绑定的未入批次托盘,分配正式托盘码并绑定批次
        bindPalletsToBatch(bagCodes, batch);
        integrityService.recordBatchIntegrity(batch.getId());
    }
    /**
@@ -273,6 +290,7 @@
            }
            String palletCode = codeRedisDao.generate();
            palletMapper.updateById(new MesProPalletDO().setId(palletId)
                    .setItemId(batch.getItemId())
                    .setCode(palletCode)
                    .setBatchId(batch.getId())
                    .setBatchCode(batch.getCode())
@@ -345,6 +363,7 @@
            MesProBagCodeDO newBagCode = new MesProBagCodeDO()
                    .setUuid(replaceUuid)
                    .setCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setBagNo(newBagNo)
                    .setBatchId(batch.getId())
                    .setBatchCode(batch.getCode())
@@ -447,9 +466,16 @@
        }
        // 2. é”å®šå¹¶æ ¡éªŒè¢‹ç 
        List<MesProBagCodeDO> bagCodes = validateBagCodesForBind(bindReqVO.getIds(), pallet, true);
        Long itemId = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.resolve(pallet,
                bagCodeMapper.selectListByPalletId(pallet.getId()));
        for (MesProBagCodeDO bag : bagCodes) {
            if (bag.getItemId() == null) throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "袋码缺少品种");
            itemId = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.merge(itemId, bag.getItemId());
        }
        palletMapper.updateById(new MesProPalletDO().setId(pallet.getId()).setItemId(itemId));
        // 3. ç»‘定袋码并累加托盘已装袋数(托盘无容量上限)
        int currentCount = pallet.getBagCount() == null ? 0 : pallet.getBagCount();
        bagCodeMapper.bindPalletByIds(bindReqVO.getIds(), pallet.getId(), pallet.getCode());
        bagCodeMapper.bindPalletByIds(bindReqVO.getIds(), pallet.getId(), StrUtil.isNotBlank(pallet.getCode()) ? pallet.getCode() : pallet.getTempCode());
        palletService.updatePalletBagCount(pallet.getId(), currentCount + bagCodes.size());
    }
@@ -501,6 +527,32 @@
    @Override
    public MesProBagCodeDO getBagCodeByCode(String code) {
        return bagCodeMapper.selectByCode(code);
    }
    @Override
    public MesProBagCodeDO getBagCodeByScanCode(String code) {
        return bagCodeMapper.selectByScanCode(code);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int generateBusinessCodes(List<Long> ids) {
        if (CollUtil.isEmpty(ids) || ids.size() > 200 || ids.stream().anyMatch(Objects::isNull)) {
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "请选择 1 è‡³ 200 æ¡è¢‹ç ");
        }
        List<Long> uniqueIds = ids.stream().distinct().sorted().toList();
        List<MesProBagCodeDO> bags = bagCodeMapper.selectListByIdsForUpdate(uniqueIds);
        if (bags.size() != uniqueIds.size()) throw exception(PRO_BAG_CODE_NOT_EXISTS);
        if (bags.stream().anyMatch(bag -> MesProBagCodeStatusEnum.CANCELED.getStatus().equals(bag.getStatus()))) {
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "已作废袋码不可补生成业务码");
        }
        int count = 0;
        for (MesProBagCodeDO bag : bags) {
            if (StrUtil.isNotBlank(bag.getBusinessCode())) continue;
            bagCodeMapper.updateById(new MesProBagCodeDO().setId(bag.getId()).setBusinessCode(codeRedisDao.generate()));
            count++;
        }
        return count;
    }
    @Override
@@ -564,7 +616,7 @@
            throw exception(PRO_BAG_CODE_NOT_EXISTS);
        }
        for (MesProBagCodeDO bagCode : bagCodes) {
            if (!pallet.getBatchId().equals(bagCode.getBatchId())) {
            if (!Objects.equals(pallet.getBatchId(), bagCode.getBatchId())) {
                throw exception(PRO_BAG_CODE_BIND_PALLET_MISMATCH);
            }
            if (bagCode.getPalletId() != null) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/batch/MesProBatchService.java
@@ -87,6 +87,11 @@
     */
    MesProBatchDO getBatchByCode(String code);
    /** å†…部扫码识别业务码及历史正式/临时码。 */
    MesProBatchDO getBatchByScanCode(String code);
    int generateBusinessCodes(java.util.List<Long> ids);
    /**
     * èŽ·å¾—ç”Ÿäº§æ‰¹æ¬¡åˆ†é¡µ
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/batch/MesProBatchServiceImpl.java
@@ -80,6 +80,7 @@
        // 1. æ ¡éªŒå…³è”数据(产线、生产人员)
        MesProLineDO line = lineService.validateLineExistsAndEnable(createReqVO.getLineId());
        validateRelations(createReqVO);
        createReqVO.setItemId(resolveBindingItem(null, createReqVO));
        // 2. ç”Ÿæˆæ‰¹æ¬¡å·ï¼ˆç¼–码规则:前缀 + æ—¥æœŸ + äº§çº¿ç¼–码 + æµæ°´å·ï¼‰
        String code = generateBatchCode(line.getCode());
@@ -88,6 +89,7 @@
        boolean bindPallets = CollUtil.isNotEmpty(createReqVO.getPalletIds());
        MesProBatchDO batch = BeanUtils.toBean(createReqVO, MesProBatchDO.class)
                .setCode(code)
                .setBusinessCode(codeRedisDao.generate())
                .setLineCode(line.getCode())
                .setLineName(line.getName())
                .setBagQuantity(0)
@@ -108,12 +110,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public List<MesProBatchDO> preGenerateBatch(MesProBatchPreGenerateReqVO reqVO) {
        // 1. ç”Ÿæˆä¸´æ—¶æ‰¹æ¬¡ç ï¼ˆå…¨å±€ 8 ä½åºå·ï¼Œæ ¼å¼ PB-00000001)
        int startSeq = batchMapper.selectMaxTempCodeSeq() + 1;
        // 1. é€šè¿‡ç»Ÿä¸€ç¼–码生成器分配临时码及业务码,不再解析历史临时码序号。
        List<MesProBatchDO> list = new ArrayList<>(reqVO.getCount());
        for (int i = 0; i < reqVO.getCount(); i++) {
            list.add(new MesProBatchDO()
                    .setTempCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setBagQuantity(0)
                    .setPalletQuantity(0)
                    .setStatus(MesProBatchStatusEnum.DRAFT.getStatus())
@@ -140,6 +142,7 @@
        // 2. æ ¡éªŒå…³è”数据并生成正式批次号
        MesProLineDO line = lineService.validateLineExistsAndEnable(fillReqVO.getLineId());
        validateRelations(fillReqVO);
        fillReqVO.setItemId(resolveBindingItem(batch, fillReqVO));
        String code = generateBatchCode(line.getCode());
        // 3. è½åº“业务信息(保留临时批次码用于历史扫码追溯;产线编码/名称冗余回填;勾选绑定托盘时批次直接进入生产中)
        boolean bindPallets = CollUtil.isNotEmpty(fillReqVO.getPalletIds());
@@ -163,10 +166,12 @@
    @Transactional(rollbackFor = Exception.class)
    public void updateBatch(MesProBatchSaveReqVO updateReqVO) {
        // 1. æ ¡éªŒå­˜åœ¨ + çŠ¶æ€ï¼ˆè‰ç¨¿/生产中才允许修改)
        MesProBatchDO oldBatch = validateBatchExists(updateReqVO.getId());
        MesProBatchDO oldBatch = batchMapper.selectByIdForUpdate(updateReqVO.getId());
        if (oldBatch == null) throw exception(PRO_BATCH_NOT_EXISTS);
        validateBatchEditable(oldBatch);
        // 2. æ ¡éªŒå…³è”数据(生产人员)
        validateRelations(updateReqVO);
        updateReqVO.setItemId(resolveBindingItem(oldBatch, updateReqVO));
        // 3. æœªç”Ÿæˆæ­£å¼æ‰¹æ¬¡å·æˆ–产线变化时重新生成批次号(批次号包含产线编码要素)
        boolean codeEmpty = StrUtil.isEmpty(oldBatch.getCode());
@@ -250,6 +255,33 @@
    }
    @Override
    public MesProBatchDO getBatchByScanCode(String code) {
        return batchMapper.selectByScanCode(code);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int generateBusinessCodes(java.util.List<Long> ids) {
        if (ids == null || ids.isEmpty() || ids.size() > 200 || ids.stream().anyMatch(java.util.Objects::isNull))
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "请选择 1 è‡³ 200 æ¡è®°å½•");
        java.util.List<MesProBatchDO> records = new java.util.ArrayList<>();
        for (Long id : ids.stream().distinct().sorted().toList()) {
            MesProBatchDO record = batchMapper.selectByIdForUpdate(id);
            if (record == null) throw exception(PRO_BATCH_NOT_EXISTS);
            if (MesProBatchStatusEnum.CANCELED.getStatus().equals(record.getStatus()))
                throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "已作废记录不可补生成业务码");
            records.add(record);
        }
        int count = 0;
        for (MesProBatchDO record : records) {
            if (StrUtil.isNotBlank(record.getBusinessCode())) continue;
            batchMapper.updateById(new MesProBatchDO().setId(record.getId()).setBusinessCode(codeRedisDao.generate()));
            count++;
        }
        return count;
    }
    @Override
    public MesProBatchDO getBatchByCode(String code) {
        MesProBatchDO batch = batchMapper.selectByCode(code);
        if (batch == null) {
@@ -311,6 +343,28 @@
     * æ‰¹æ¬¡ç»‘定托盘(可选):同事务内复用托盘绑定批次逻辑,
     * ç”Ÿæˆæ­£å¼æ‰˜ç›˜ç å¹¶çº§è”录入托盘下未绑批次的袋码,失败整体回滚
     */
    private Long resolveBindingItem(MesProBatchDO batch, MesProBatchSaveReqVO req) {
        Long itemId = batch == null ? null : batch.getItemId();
        if (batch != null) {
            for (Long existing : bagCodeMapper.selectDistinctItemIdsByBatchId(batch.getId()))
                itemId = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.merge(itemId, existing);
            for (var pallet : palletMapper.selectListByBatchId(batch.getId()))
                itemId = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.merge(itemId,
                        cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.resolve(pallet, bagCodeMapper.selectListByPalletId(pallet.getId())));
        }
        if (CollUtil.isNotEmpty(req.getPalletIds())) for (Long id : req.getPalletIds().stream().distinct().sorted().toList()) {
            var pallet = palletMapper.selectByIdForUpdate(id);
            if (pallet == null) throw exception(PRO_PALLET_NOT_EXISTS);
            Long candidate = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.resolve(pallet, bagCodeMapper.selectListByPalletId(id));
            if (candidate == null) throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "托盘缺少品种,请先绑定同品种袋码");
            itemId = cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletItemResolver.merge(itemId, candidate);
        }
        // è¯·æ±‚里的品种只能与已绑定/待绑定托盘一致,不能通过编辑改变批次品种。
        if (req.getItemId() != null && !Objects.equals(req.getItemId(), itemId))
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "品种由绑定托盘带入,不可修改为其他品种");
        return itemId;
    }
    private void bindPalletsToBatch(Long batchId, List<Long> palletIds) {
        if (CollUtil.isEmpty(palletIds)) {
            return;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletItemResolver.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.mes.service.pro.pallet;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import java.util.*;
/** æ‰˜ç›˜å“ç§ä»¥æ‰˜ç›˜è®°å½•和有效袋码共同校验,兼容历史托盘。 */
public final class MesProPalletItemResolver {
    private MesProPalletItemResolver() {}
    public static Long resolve(MesProPalletDO pallet, List<MesProBagCodeDO> bags) {
        Set<Long> items = new HashSet<>();
        if (pallet.getItemId() != null) items.add(pallet.getItemId());
        for (MesProBagCodeDO bag : bags) {
            if (Integer.valueOf(30).equals(bag.getStatus())) continue;
            if (bag.getItemId() == null) throw new ServiceException(400, "托盘内袋码缺少品种,请先完善品种信息");
            items.add(bag.getItemId());
        }
        if (items.size() > 1) throw new ServiceException(400, "托盘和袋码必须属于同一品种");
        return items.isEmpty() ? null : items.iterator().next();
    }
    public static Long merge(Long current, Long candidate) {
        if (current != null && candidate != null && !current.equals(candidate))
            throw new ServiceException(400, "生产批次只能绑定相同品种的托盘,且须与批次品种一致");
        return current == null ? candidate : current;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletService.java
@@ -73,6 +73,11 @@
     */
    MesProPalletDO getPalletByCode(String code);
    /** å†…部扫码识别业务码及历史正式/临时码。 */
    MesProPalletDO getPalletByScanCode(String code);
    int generateBusinessCodes(java.util.List<Long> ids);
    /**
     * èŽ·å¾—æ‰˜ç›˜ç åˆ†é¡µ
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletServiceImpl.java
@@ -51,6 +51,8 @@
    @Resource
    private MesCodeRedisDAO codeRedisDao;
    @Resource
    private cn.iocoder.yudao.module.mes.service.trace.integrity.MesTraceIntegrityService integrityService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -76,6 +78,8 @@
            int palletNo = startNo + i;
            pallets.add(new MesProPalletDO()
                    .setCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setItemId(batch.getItemId())
                    .setBatchId(batch.getId())
                    .setBatchCode(batch.getCode())
                    .setPalletNo(palletNo)
@@ -96,12 +100,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public List<MesProPalletDO> preGeneratePallet(MesProPalletPreGenerateReqVO reqVO) {
        // 1. ç”Ÿæˆä¸´æ—¶æ‰˜ç›˜ç ï¼ˆå…¨å±€ 8 ä½åºå·ï¼Œæ ¼å¼ PL-00000001)
        int startSeq = palletMapper.selectMaxTempCodeSeq() + 1;
        // 1. é€šè¿‡ç»Ÿä¸€ç¼–码生成器分配临时码及业务码,不再解析历史临时码序号。
        List<MesProPalletDO> list = new ArrayList<>(reqVO.getCount());
        for (int i = 0; i < reqVO.getCount(); i++) {
            list.add(new MesProPalletDO()
                    .setTempCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setBagCount(0)
                    .setAutoGenerated(false)
                    .setStatus(MesProPalletStatusEnum.IN_USE.getStatus())
@@ -142,6 +146,14 @@
            }
            pallets.add(pallet);
        }
        Long itemId = batch.getItemId();
        for (Long existing : bagCodeMapper.selectDistinctItemIdsByBatchId(batch.getId()))
            itemId = MesProPalletItemResolver.merge(itemId, existing);
        for (MesProPalletDO pallet : pallets) {
            Long palletItem = MesProPalletItemResolver.resolve(pallet, bagCodeMapper.selectListByPalletId(pallet.getId()));
            if (palletItem == null) throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "托盘缺少品种,请先绑定同品种袋码");
            itemId = MesProPalletItemResolver.merge(itemId, palletItem);
        }
        // 3. çº§è”准备:托盘下未绑批次的袋码随托盘一起录入批次,品种须与批次已有袋码一致
        List<MesProBagCodeDO> bags = bagCodeMapper.selectUnboundListByPalletIds(ids);
        List<Long> bindBagIds = new ArrayList<>();
@@ -158,6 +170,7 @@
        }
        if (!bindBagIds.isEmpty()) {
            bagItemIds.addAll(bagCodeMapper.selectDistinctItemIdsByBatchId(batch.getId()));
            if (itemId != null) bagItemIds.add(itemId);
            if (bagItemIds.size() > 1) {
                throw exception(PRO_BAG_CODE_BIND_BATCH_ITEM_MISMATCH);
            }
@@ -166,15 +179,17 @@
        int startNo = palletMapper.selectMaxPalletNo(batch.getId()) + 1;
        for (int i = 0; i < pallets.size(); i++) {
            int palletNo = startNo + i;
            String palletCode = codeRedisDao.generate();
            palletMapper.updateById(new MesProPalletDO()
                    .setId(pallets.get(i).getId())
                    .setCode(codeRedisDao.generate())
                    .setItemId(itemId)
                    .setCode(palletCode)
                    .setBatchId(batch.getId())
                    .setBatchCode(batch.getCode())
                    .setPalletNo(palletNo));
            // æ‰˜ç›˜ä¸‹è¢‹ç çš„冗余托盘码由临时码更新为正式码
            bagCodeMapper.updatePalletCodeByPalletId(pallets.get(i).getId(),
                    batch.getCode() + "-P" + StrUtil.padPre(String.valueOf(palletNo), 2, '0'));
                    palletCode);
        }
        // 5. æ‰˜ç›˜ä¸‹è¢‹ç æ‰¹é‡ç»‘定本批次
        if (!bindBagIds.isEmpty() && bagCodeMapper.bindBatchByIds(bindBagIds, batch.getId(), batch.getCode(),
@@ -182,10 +197,11 @@
                        MesProBagCodeStatusEnum.PRINTED.getStatus())) != bindBagIds.size()) {
            throw exception(PRO_BAG_CODE_STATUS_NOT_GENERATED);
        }
        // 6. å›žå†™æ‰¹æ¬¡æ‰˜ç›˜ç æ•°é‡ä¸Žæœ‰æ•ˆè¢‹ç æ•°é‡
        batchMapper.updateById(new MesProBatchDO().setId(batch.getId())
        // 6. å›žå†™æ‰¹æ¬¡å“ç§ã€æ‰˜ç›˜ç æ•°é‡ä¸Žæœ‰æ•ˆè¢‹ç æ•°é‡
        batchMapper.updateById(new MesProBatchDO().setId(batch.getId()).setItemId(itemId)
                .setPalletQuantity((batch.getPalletQuantity() == null ? 0 : batch.getPalletQuantity()) + pallets.size())
                .setBagQuantity(bagCodeMapper.selectEffectiveCountByBatchId(batch.getId())));
        integrityService.recordBatchIntegrity(batch.getId());
        return pallets.size();
    }
@@ -214,6 +230,33 @@
    }
    @Override
    public MesProPalletDO getPalletByScanCode(String code) {
        return palletMapper.selectByScanCode(code);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int generateBusinessCodes(java.util.List<Long> ids) {
        if (ids == null || ids.isEmpty() || ids.size() > 200 || ids.stream().anyMatch(java.util.Objects::isNull))
            throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "请选择 1 è‡³ 200 æ¡è®°å½•");
        java.util.List<MesProPalletDO> records = new java.util.ArrayList<>();
        for (Long id : ids.stream().distinct().sorted().toList()) {
            MesProPalletDO record = palletMapper.selectByIdForUpdate(id);
            if (record == null) throw exception(PRO_PALLET_NOT_EXISTS);
            if (MesProPalletStatusEnum.CANCELED.getStatus().equals(record.getStatus()))
                throw new cn.iocoder.yudao.framework.common.exception.ServiceException(400, "已作废记录不可补生成业务码");
            records.add(record);
        }
        int count = 0;
        for (MesProPalletDO record : records) {
            if (StrUtil.isNotBlank(record.getBusinessCode())) continue;
            palletMapper.updateById(new MesProPalletDO().setId(record.getId()).setBusinessCode(codeRedisDao.generate()));
            count++;
        }
        return count;
    }
    @Override
    public MesProPalletDO getPalletByCode(String code) {
        MesProPalletDO pallet = palletMapper.selectByCode(code);
        if (pallet == null) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/scan/MesProScanEventServiceImpl.java
@@ -32,6 +32,12 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBatchStatusEnum;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
@Slf4j
@Service
@@ -55,20 +61,31 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesProScanEventDO createInbound(MesProScanEventCreateReqVO req) {
        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())
                    && (req.getDeviceCode() == null || req.getDeviceCode().equals(existing.getDeviceCode()));
                    && Objects.equals(req.getDeviceCode(), existing.getDeviceCode())
                    && Objects.equals(req.getLineCode(), existing.getLineCode())
                    && Objects.equals(scanType, existing.getScanType())
                    && Objects.equals(req.getLocationId(), existing.getLocationId())
                    && Objects.equals(req.getAreaId(), existing.getAreaId())
                    && (!SCAN_TYPE_BAG.equals(scanType) || (existing.getQuantity() != null
                        && req.getQuantity().compareTo(existing.getQuantity()) == 0))
                    && Objects.equals(String.valueOf(SecurityFrameworkUtils.getLoginUserId()), existing.getCreator());
            if (!contentMatch) {
                return failed(existing, "MES_SCAN_EVENT_ID_CONFLICT", "事件号已存在但内容不一致");
                throw new ServiceException(409, "事件号已存在但内容或操作人不一致,请核实原操作");
            }
            return existing;
        }
        String scanType = (req.getScanType() == null || req.getScanType().isBlank()) ? SCAN_TYPE_BAG : req.getScanType();
        MesProScanEventDO event = new MesProScanEventDO();
        event.setEventId(req.getEventId()).setBagCode(req.getBagCode()).setScanType(scanType)
                .setQuantity(req.getQuantity()).setDeviceCode(req.getDeviceCode()).setLineCode(req.getLineCode())
                .setRemark(req.getRemark()).setStatus(MesProScanEventStatusEnum.FAILED).setReceivedTime(LocalDateTime.now());
                .setRemark(req.getRemark()).setLocationId(req.getLocationId()).setAreaId(req.getAreaId())
                .setStatus(MesProScanEventStatusEnum.FAILED).setReceivedTime(LocalDateTime.now());
        eventMapper.insert(event);
        // 1. æŒ‰æ‰«ç ç±»åž‹è§£æžæ‰«ç ç›®æ ‡ä¸Žå…¥åº“数量(托盘按已装袋数、批次按批次袋数整批/整托入库)
        Long targetId;
@@ -79,37 +96,31 @@
        Long bagCodeId = null;
        String bagCode = null;
        if (SCAN_TYPE_PALLET.equals(scanType)) {
            MesProPalletDO pallet = palletMapper.selectByCode(req.getBagCode());
            if (pallet == null) {
                pallet = palletMapper.selectByTempCode(req.getBagCode());
            }
            MesProPalletDO pallet = palletMapper.selectByScanCodeForUpdate(req.getBagCode());
            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", "托盘已作废,不可入库");
            if (pallet.getInboundEventId() != null) return failed(event, "MES_SCAN_PALLET_ALREADY_INBOUND", "托盘已入库");
            batch = batchMapper.selectByIdForUpdate(pallet.getBatchId());
            if (batch == null) return failed(event, "MES_SCAN_BATCH_NOT_EXISTS", "生产批次不存在");
            batchId = batch.getId();
            targetId = pallet.getId();
            quantity = pallet.getBagCount() != null && pallet.getBagCount() > 0
                    ? BigDecimal.valueOf(pallet.getBagCount())
                    : BigDecimal.valueOf(bagCodeMapper.selectCountByPalletId(pallet.getId()));
            quantity = BigDecimal.valueOf(bagCodeMapper.selectPendingInboundCountByPalletId(pallet.getId()));
        } else if (SCAN_TYPE_BATCH.equals(scanType)) {
            batch = batchMapper.selectByCode(req.getBagCode());
            if (batch == null) {
                batch = batchMapper.selectByTempCode(req.getBagCode());
            }
            batch = batchMapper.selectByScanCodeForUpdate(req.getBagCode());
            if (batch == null) return failed(event, "MES_SCAN_BATCH_NOT_EXISTS", "生产批次不存在");
            if (batch.getInboundEventId() != null) return failed(event, "MES_SCAN_BATCH_ALREADY_INBOUND", "批次已整批入库");
            batchId = batch.getId();
            targetId = batch.getId();
            quantity = batch.getBagQuantity() != null && batch.getBagQuantity() > 0
                    ? BigDecimal.valueOf(batch.getBagQuantity())
                    : BigDecimal.valueOf(bagCodeMapper.selectCountByBatchId(batch.getId()));
            quantity = BigDecimal.valueOf(bagCodeMapper.selectPendingInboundCountByBatchId(batch.getId()));
        } else {
            MesProBagCodeDO bag = bagCodeMapper.selectByCodeForUpdate(req.getBagCode());
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(req.getBagCode());
            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", "袋码已作废,不可入库");
            event.setBagCodeId(bag.getId());
            bagCodeId = bag.getId();
            bagCode = req.getBagCode();
            // åº“存流水保持原追溯码为规范码,业务码仅用于查找同一袋,避免双码拆成两份库存。
            bagCode = bag.getCode();
            if (bag.getInboundEventId() != null) return failed(event, "MES_SCAN_BAG_ALREADY_INBOUND", "袋码已入库");
            batch = batchMapper.selectByIdForUpdate(bag.getBatchId());
            if (batch == null) return failed(event, "MES_SCAN_BATCH_NOT_EXISTS", "生产批次不存在");
@@ -117,7 +128,11 @@
            targetId = bag.getId();
            quantity = req.getQuantity();
        }
        event.setBatchId(batchId).setBatchCode(batch.getCode()).setItemId(batch.getItemId());
        if (MesProBatchStatusEnum.CANCELED.getStatus().equals(batch.getStatus()))
            return failed(event, "MES_SCAN_BATCH_CANCELED", "生产批次已作废,不可入库");
        if (quantity.signum() <= 0)
            return failed(event, "MES_SCAN_NO_PENDING_BAGS", "没有待入库的有效袋码,请勿重复入库");
        event.setBatchId(batchId).setBatchCode(batch.getCode()).setItemId(batch.getItemId()).setQuantity(quantity);
        MesWmWarehouseDO warehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
        if (warehouse == null) return failed(event, "MES_SCAN_WIP_WAREHOUSE_NOT_EXISTS", "WIP虚拟仓不存在");
        event.setWarehouseId(warehouse.getId()).setLocationId(req.getLocationId()).setAreaId(req.getAreaId());
@@ -143,12 +158,12 @@
                palletMapper.markInbound(targetId, event.getId());
                bagCodeMapper.markInboundByPalletId(targetId, event.getId(), req.getLineCode());
            } else if (SCAN_TYPE_BATCH.equals(scanType)) {
                batchMapper.markBatchInbound(targetId, event.getId(), req.getLineCode(), batch.getBagQuantity());
                bagCodeMapper.markInboundByBatchId(targetId, event.getId(), req.getLineCode());
            } else {
                bagCodeMapper.markInbound(targetId, event.getId(), req.getDeviceCode(), req.getLineCode());
                batchMapper.markInbound(batchId, event.getId(), req.getDeviceCode(), req.getLineCode());
            }
            palletMapper.markCompletedInboundByBatchId(batchId, event.getId());
            batchMapper.refreshInboundProgress(batchId, event.getId(), req.getDeviceCode(), req.getLineCode());
            return event;
        } catch (Exception ex) {
            log.error("[createInbound] åº“存事务处理失败,eventId={}, bagCode={}, batchId={}, itemId={}",
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityService.java
@@ -12,6 +12,10 @@
    Long submit(MesQcSeedQualitySubmitReqVO reqVO);
    java.util.Map<String, Object> resolveScan(String code);
    java.util.Map<String, Object> resolveScan(String code, String scanType);
    java.util.Map<String, Object> submitGroup(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO reqVO);
    PageResult<MesQcSeedQualityDO> getQualityPage(MesQcSeedQualityPageReqVO reqVO);
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java
@@ -53,6 +53,8 @@
    @Resource private MesQcSeedQualityMapper qualityMapper;
    @Resource private MesQcSeedQualityIndicatorMapper indicatorMapper;
    @Resource private MesProBagCodeMapper bagCodeMapper;
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper palletMapper;
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper batchMapper;
    @Resource private MesProBatchService proBatchService;
    @Resource private MesWmBatchMapper mesWmBatchMapper;
    @Resource private MesWmMaterialStockMapper materialStockMapper;
@@ -65,22 +67,120 @@
    @Override
    public PageResult<MesQcSeedQualityDO> getQualityPage(MesQcSeedQualityPageReqVO reqVO) {
        if (StrUtil.isNotBlank(reqVO.getCode())) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(reqVO.getCode());
            if (bag != null) reqVO.setCode(bag.getCode());
        }
        return qualityMapper.selectPage(reqVO);
    }
    @Override
    public Map<String, Object> resolveScan(String code) {
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(code);
        if (bag == null) throw new IllegalArgumentException("未解析到有效袋码,请扫描袋码");
        Map<String, Object> result = new HashMap<>();
        result.put("code", bag.getCode());
        result.put("businessCode", bag.getBusinessCode());
        result.put("batchCode", bag.getBatchCode());
        result.put("inbound", bag.getInboundEventId() != null);
        result.put("canceled", Integer.valueOf(30).equals(bag.getStatus()));
        MesQcSeedQualityDO quality = qualityMapper.selectByCode(bag.getCode());
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        return result;
    }
    private record Scope(String code, String businessCode, String batchCode, boolean canceled, List<MesProBagCodeDO> bags) { }
    private Scope scope(String code, String type, boolean lock) {
        if ("BAG".equals(type)) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(code);
            if (bag == null) throw new IllegalArgumentException("未找到袋码,请核对扫码类型");
            return new Scope(bag.getCode(), bag.getBusinessCode(), bag.getBatchCode(), Integer.valueOf(30).equals(bag.getStatus()), List.of(bag));
        }
        if ("PALLET".equals(type)) {
            var pallet = lock ? palletMapper.selectByScanCodeForUpdate(code) : palletMapper.selectByScanCode(code);
            if (pallet == null) throw new IllegalArgumentException("未找到托盘码,请核对扫码类型");
            if (pallet.getBatchId() == null) throw new IllegalArgumentException("托盘尚未绑定生产批次,无法质检");
            return new Scope(StrUtil.blankToDefault(pallet.getCode(), pallet.getTempCode()), pallet.getBusinessCode(), pallet.getBatchCode(),
                    Integer.valueOf(20).equals(pallet.getStatus()), lock ? bagCodeMapper.selectListByPalletIdForUpdate(pallet.getId()) : bagCodeMapper.selectListByPalletId(pallet.getId()));
        }
        if ("BATCH".equals(type)) {
            var batch = lock ? batchMapper.selectByScanCodeForUpdate(code) : batchMapper.selectByScanCode(code);
            if (batch == null) throw new IllegalArgumentException("未找到生产批次码,请核对扫码类型");
            return new Scope(StrUtil.blankToDefault(batch.getCode(), batch.getTempCode()), batch.getBusinessCode(), batch.getCode(),
                    Integer.valueOf(40).equals(batch.getStatus()), lock ? bagCodeMapper.selectListByBatchIdForUpdate(batch.getId()) : bagCodeMapper.selectListByBatchId(batch.getId()));
        }
        throw new IllegalArgumentException("不支持的质检扫码类型");
    }
    @Override
    public Map<String, Object> resolveScan(String code, String scanType) {
        Scope scope = scope(code, scanType, false);
        List<MesProBagCodeDO> effective = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).toList();
        Set<String> inspected = qualityMapper.selectListByCodes(effective.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        Map<String, Object> result = new HashMap<>();
        result.put("scanType", scanType); result.put("code", scope.code()); result.put("businessCode", scope.businessCode());
        result.put("batchCode", scope.batchCode()); result.put("canceled", scope.canceled());
        result.put("totalCount", effective.size());
        result.put("inbound", !effective.isEmpty() && effective.stream().allMatch(b -> b.getInboundEventId() != null));
        result.put("pendingBagIds", effective.stream().filter(b -> b.getInboundEventId() != null && !inspected.contains(b.getCode())).map(MesProBagCodeDO::getId).sorted().toList());
        result.put("completedBagIds", effective.stream().filter(b -> inspected.contains(b.getCode())).map(MesProBagCodeDO::getId).sorted().toList());
        if ("BAG".equals(scanType)) {
            MesQcSeedQualityDO quality = qualityMapper.selectByCode(scope.code());
            result.put("qualityId", quality == null ? null : quality.getId()); result.put("result", quality == null ? null : quality.getResult());
        }
        return result;
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> submitGroup(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO req) {
        Scope scope = scope(req.getCode(), req.getScanType(), true);
        if (scope.canceled()) throw new IllegalArgumentException("质检对象已作废");
        List<MesProBagCodeDO> effective = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).toList();
        if (effective.isEmpty()) throw new IllegalArgumentException("没有有效袋码");
        List<MesProBagCodeDO> locked = bagCodeMapper.selectListByIdsForUpdate(effective.stream().map(MesProBagCodeDO::getId).toList());
        if (locked.size() != effective.size() || locked.stream().anyMatch(b -> Integer.valueOf(30).equals(b.getStatus()) || b.getInboundEventId() == null))
            throw new IllegalArgumentException("袋码已变化或尚未全部入库,请重新扫描核对");
        // åœ¨è¢‹ç é”ä¹‹åŽé‡æ–°æ ¸å¯¹å½’属;其他绑定操作不得改变已确认的检验范围。
        Scope current = scope(req.getCode(), req.getScanType(), false);
        Set<Long> currentIds = current.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).map(MesProBagCodeDO::getId).collect(java.util.stream.Collectors.toSet());
        if (!currentIds.equals(new HashSet<>(locked.stream().map(MesProBagCodeDO::getId).toList())))
            throw new IllegalArgumentException("袋码归属已变化,请重新扫描核对");
        Set<String> inspected = qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        List<MesProBagCodeDO> pending = locked.stream().filter(b -> !inspected.contains(b.getCode())).sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
        List<Long> confirmed = req.getBagIds().stream().sorted().toList();
        if (pending.isEmpty() || !confirmed.equals(pending.stream().map(MesProBagCodeDO::getId).toList()))
            throw new IllegalArgumentException("待质检清单已变化或已全部质检,请重新扫描,禁止重复处理");
        for (MesProBagCodeDO bag : pending) {
            MesQcSeedQualitySubmitReqVO single = new MesQcSeedQualitySubmitReqVO();
            single.setCode(bag.getCode());single.setIndicators(req.getIndicators());single.setReason(req.getReason());
            single.setQualifiedWarehouseId(req.getQualifiedWarehouseId());
            // å¤–层事务覆盖全部袋码;任何一袋库存流转失败都会回滚整次操作。
            submit(single);
        }
        return Map.of("processedCount", pending.size());
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Long submit(MesQcSeedQualitySubmitReqVO reqVO) {
        if (reqVO.getIndicators().size() != 4) throw new IllegalArgumentException("必须提交四项指标");
        if (qualityMapper.selectByCode(reqVO.getCode()) != null) throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        // ç§å­è´¨æ£€å¿…须以袋码(单袋)提交,物料信息由袋码自动解析,前端不再单独选择物料
        MesProBagCodeDO bag = bagCodeMapper.selectByCode(reqVO.getCode());
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(reqVO.getCode());
        if (bag == null) {
            throw new IllegalArgumentException("未解析到有效袋码,种子质检必须以袋码提交");
        }
        MesProBatchDO batch = resolveBatch(reqVO.getCode(), reqVO.getBatchCode());
        if (Integer.valueOf(30).equals(bag.getStatus()) || bag.getInboundEventId() == null)
            throw new IllegalArgumentException("袋码已作废或尚未入库,无法质检");
        // åŒç é”å®šåŒä¸€è¢‹ï¼Œå¹¶ä»¥è¿½æº¯ç è®°å½•结果,防止换码或并发重复扣减库存。
        if (qualityMapper.selectByCodeForUpdate(bag.getCode()) != null) throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        MesProBatchDO batch = bag.getBatchId() == null ? null : proBatchService.getBatch(bag.getBatchId());
        if (batch == null || batch.getItemId() == null) {
            throw new IllegalArgumentException("未根据袋码解析到物料信息,请确认扫码内容或批次号正确");
        }
        if (Integer.valueOf(40).equals(batch.getStatus())) throw new IllegalArgumentException("生产批次已作废,无法质检");
        // è¢‹ç å¯¹åº”批次的库存必须处于暂存库(待质检)中,才能进行种子质检
        MesWmBatchDO wmBatch = mesWmBatchMapper.selectByCode(batch.getCode());
        if (wmBatch == null || materialStockMapper.selectTempStockCountByBatchId(wmBatch.getId()) == 0) {
@@ -110,7 +210,7 @@
        }
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(reqVO.getCode()).itemId(batch.getItemId())
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(bag.getCode()).itemId(batch.getItemId())
                .batchCode(batch.getCode()).reason(reqVO.getReason()).result(0)
                .inspectedAt(LocalDateTime.now()).build();
        qualityMapper.insert(quality);
@@ -121,6 +221,7 @@
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
        if (!codes.equals(REQUIRED)) throw new IllegalArgumentException("四项指标不完整");
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        quality.setResult(passed ? 1 : 2);
        qualityMapper.updateById(quality);
        // æŒ‰è¢‹ç ç²’度流转库存:锁定该袋在暂存库中的活动明细,禁止按批次任意扣减
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesDetailServiceImpl.java
@@ -55,7 +55,8 @@
    @Override
    public void updateProductSalesDetail(MesWmProductSalesDetailSaveReqVO updateReqVO) {
        // æ ¡éªŒå­˜åœ¨
        validateProductSalesDetailExists(updateReqVO.getId());
        MesWmProductSalesDetailDO existing = validateProductSalesDetailExists(updateReqVO.getId());
        if (existing.getBagCodeId() != null) throw new IllegalArgumentException("扫码袋码明细不能手工修改数量或库存,请删除后重新扫码选择");
        // æ ¡éªŒæ•°æ®
        validateProductSalesDetailSaveData(updateReqVO);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesService.java
@@ -84,6 +84,9 @@
    MesWmProductSalesScanRespVO scanProductSales(@Valid MesWmProductSalesScanReqVO reqVO);
    MesWmProductSalesScanRespVO previewScan(String code);
    MesWmProductSalesScanRespVO previewScan(String code, String scanType, Long salesId);
    /**
     * å¡«å†™è¿å•(待填写运单 â†’ å¾…执行出库)
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -52,7 +52,13 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstockbag.MesWmMaterialStockBagMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstockbag.MesWmMaterialStockBagDO;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@@ -103,6 +109,10 @@
    @Resource
    private MesTraceIntegrityService integrityService;
    @Resource private MesProBagCodeMapper pickingBagMapper;
    @Resource private MesProPalletMapper pickingPalletMapper;
    @Resource private MesProBatchMapper pickingBatchMapper;
    @Resource private MesWmMaterialStockBagMapper pickingStockBagMapper;
    @Override
    public Long createProductSales(MesWmProductSalesSaveReqVO createReqVO) {
@@ -222,83 +232,150 @@
        return true;
    }
    private record PickingScope(String type, Long id, MesProBatchDO batch, List<MesProBagCodeDO> bags) { }
    private PickingScope pickingScope(String code, String type, boolean lock) {
        if (type == null || type.isBlank()) {
            type = palletService.getPalletByScanCode(code) != null ? "PALLET"
                    : bagCodeService.getBagCodeByScanCode(code) != null ? "BAG" : "BATCH";
        }
        MesProBatchDO batch;
        List<MesProBagCodeDO> bags;
        Long id;
        switch (type) {
            case "BAG" -> {
                MesProBagCodeDO bag = lock ? pickingBagMapper.selectByScanCodeForUpdate(code) : bagCodeService.getBagCodeByScanCode(code);
                if (bag == null || Integer.valueOf(30).equals(bag.getStatus())) throw new IllegalArgumentException("袋码不存在或已作废");
                id = bag.getId(); batch = proBatchService.getBatch(bag.getBatchId()); bags = List.of(bag);
            }
            case "PALLET" -> {
                MesProPalletDO pallet = lock ? pickingPalletMapper.selectByScanCodeForUpdate(code) : palletService.getPalletByScanCode(code);
                if (pallet == null || Integer.valueOf(20).equals(pallet.getStatus())) throw new IllegalArgumentException("托盘不存在或已作废");
                if (pallet.getBatchId() == null) throw exception(PRO_PALLET_NOT_BIND_BATCH);
                id = pallet.getId(); batch = proBatchService.getBatch(pallet.getBatchId());
                bags = lock ? pickingBagMapper.selectListByPalletIdForUpdate(id) : bagCodeService.getBagCodeListByPalletId(id);
            }
            case "BATCH" -> {
                batch = lock ? pickingBatchMapper.selectByScanCodeForUpdate(code) : proBatchService.getBatchByScanCode(code);
                if (batch == null) throw new IllegalArgumentException("生产批次码不存在");
                id = batch.getId(); bags = lock ? pickingBagMapper.selectListByBatchIdForUpdate(id) : bagCodeService.getBagCodeListByBatchId(id);
            }
            default -> throw new IllegalArgumentException("不支持的拣货扫码类型");
        }
        if (batch == null || batch.getItemId() == null || Integer.valueOf(40).equals(batch.getStatus()))
            throw new IllegalArgumentException("未绑定有效品种或生产批次已作废");
        return new PickingScope(type, id, batch, bags.stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus()))
                .sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList());
    }
    private String unavailable(MesProBagCodeDO bag, MesWmMaterialStockBagDO stockBag,
            Map<Long, MesWmMaterialStockDO> stocks, List<MesWmProductSalesDetailDO> occupied, MesProBatchDO batch) {
        if (!Objects.equals(bag.getBatchId(), batch.getId())) return "袋码批次归属异常";
        if (bag.getItemId() != null && !Objects.equals(bag.getItemId(), batch.getItemId())) return "袋码品种归属异常";
        if (!MesProBagCodeQcStatusEnum.QUALIFIED.getStatus().equals(bag.getQcStatus())) return "尚未质检合格";
        if (stockBag == null || !Integer.valueOf(1).equals(stockBag.getStatus())) return "不在库或已出库";
        if (Boolean.TRUE.equals(stockBag.getFrozen())) return "袋码已冻结";
        if (!Integer.valueOf(20).equals(stockBag.getStockState()) || !Objects.equals(stockBag.getItemId(), batch.getItemId())
                || !stocks.containsKey(stockBag.getMaterialStockId()) || stockBag.getQuantity() == null || BigDecimal.ONE.compareTo(stockBag.getQuantity()) != 0)
            return "袋码不在有效合格库存中";
        if (occupied.stream().anyMatch(d -> Objects.equals(d.getBagCodeId(), bag.getId())
                || d.getBagCodeId() == null && bag.getPalletId() != null && Objects.equals(d.getPalletCodeId(), bag.getPalletId())))
            return "已加入出库单拣货";
        return null;
    }
    @Override
    public MesWmProductSalesScanRespVO previewScan(String code) { return previewScan(code, null, null); }
    /** åªè¯»è¯†åˆ«ï¼Œå¹¶è¿”回每袋的可选状态,不创建库存批次或拣货记录。 */
    @Override
    public MesWmProductSalesScanRespVO previewScan(String code, String scanType, Long salesId) {
        PickingScope scope = pickingScope(code, scanType, false);
        if (salesId != null) {
            MesWmProductSalesDO sales = validateProductSalesExists(salesId);
            if (!Integer.valueOf(2).equals(sales.getStatus())) throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
            if (productSalesLineService.getProductSalesLineListBySalesId(salesId).stream()
                    .noneMatch(l -> Objects.equals(l.getItemId(), scope.batch().getItemId()))) throw exception(WM_PRODUCT_SALES_DETAIL_ITEM_MISMATCH);
        }
        MesWmBatchDO wmBatch = mesWmBatchService.getBatchByCode(scope.batch().getCode());
        Map<Long, MesWmMaterialStockDO> stocks = wmBatch == null ? Map.of() : materialStockService
                .getQualifiedMaterialStockListByItemAndBatch(scope.batch().getItemId(), wmBatch.getId()).stream()
                .collect(Collectors.toMap(MesWmMaterialStockDO::getId, x -> x));
        Map<Long, MesWmMaterialStockBagDO> stockBags = pickingStockBagMapper.selectByBagIds(scope.bags().stream().map(MesProBagCodeDO::getId).toList(), false)
                .stream().collect(Collectors.toMap(MesWmMaterialStockBagDO::getBagCodeId, x -> x));
        List<MesWmProductSalesDetailDO> occupied = productSalesDetailMapper.selectPickingByBatchCode(scope.batch().getCode());
        List<MesWmProductSalesScanRespVO.BagOption> options = scope.bags().stream().map(b -> {
            String reason = unavailable(b, stockBags.get(b.getId()), stocks, occupied, scope.batch());
            return new MesWmProductSalesScanRespVO.BagOption().setId(b.getId()).setCode(b.getCode()).setBusinessCode(b.getBusinessCode())
                    .setPalletId(b.getPalletId()).setSelectable(reason == null).setUnavailableReason(reason);
        }).toList();
        return new MesWmProductSalesScanRespVO().setScanType(scope.type()).setCodeId(scope.id()).setScanContent(code)
                .setBatchCode(scope.batch().getCode()).setItemId(scope.batch().getItemId()).setBagCount(options.size()).setBags(options)
                .setScanQuantity(BigDecimal.valueOf(options.stream().filter(o -> Boolean.TRUE.equals(o.getSelectable())).count()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesWmProductSalesScanRespVO scanProductSales(MesWmProductSalesScanReqVO reqVO) {
        MesWmProductSalesDO sales = validateProductSalesExists(reqVO.getSalesId());
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.APPROVING.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
        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);
        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())
            throw new IllegalArgumentException("请选择具体袋码,不允许重复袋码");
        BigDecimal quantity = BigDecimal.valueOf(ids.size());
        if (reqVO.getQuantity() != null && quantity.compareTo(reqVO.getQuantity()) != 0)
            throw new IllegalArgumentException("拣货数量必须与所选袋码数量一致");
        Set<Long> selectedIds = new HashSet<>(ids);
        List<MesProBagCodeDO> selected = scope.bags().stream().filter(b -> selectedIds.contains(b.getId())).toList();
        if (selected.size() != ids.size()) throw new IllegalArgumentException("所选袋码不属于当前扫码对象或已作废,请重新核对");
        // é”å®šå®žé™…袋码和库存袋明细,跨出库单的扫码也不能重复拣选同一袋。
        List<MesWmMaterialStockBagDO> stockBagRows = pickingStockBagMapper.selectByBagIds(ids, true);
        Map<Long, MesWmMaterialStockBagDO> stockBags = stockBagRows.stream().collect(Collectors.toMap(MesWmMaterialStockBagDO::getBagCodeId, x -> x));
        List<MesWmProductSalesDetailDO> occupied = productSalesDetailMapper.selectPickingByBatchCodeForUpdate(scope.batch().getCode());
        List<MesWmProductSalesDetailDO> own = occupied.stream().filter(d -> Objects.equals(d.getSalesId(), sales.getId())).toList();
        long already = selected.stream().filter(b -> own.stream().anyMatch(d -> Objects.equals(d.getBagCodeId(), b.getId()))).count();
        if (already == selected.size()) return new MesWmProductSalesScanRespVO().setScanType(scope.type()).setCodeId(scope.id())
                .setScanContent(reqVO.getScanContent()).setSelectedBagIds(ids).setAlreadyScanned(true).setOutboundQuantity(quantity)
                .setDetailId(own.stream().filter(d -> selectedIds.contains(d.getBagCodeId())).findFirst().orElseThrow().getId()).setMessage("所选袋码已在本单拣货明细中");
        if (already > 0) throw new IllegalArgumentException("所选袋码部分已加入本单,请重新选择未拣袋码");
        MesWmBatchDO wmBatch = mesWmBatchService.getBatchByCode(scope.batch().getCode());
        if (wmBatch == null) throw exception(WM_TRANSACTION_BATCH_NOT_EXISTS);
        Map<Long, MesWmMaterialStockDO> stocks = materialStockService.getQualifiedMaterialStockListByItemAndBatch(scope.batch().getItemId(), wmBatch.getId())
                .stream().collect(Collectors.toMap(MesWmMaterialStockDO::getId, x -> x));
        for (MesProBagCodeDO bag : selected) {
            String reason = unavailable(bag, stockBags.get(bag.getId()), stocks, occupied, scope.batch());
            if (reason != null) throw new IllegalArgumentException("袋码 " + bag.getCode() + ":" + reason);
        }
        MesProPalletDO pallet = palletService.getPalletByCode(reqVO.getScanContent());
        MesProBagCodeDO bag = pallet == null ? bagCodeService.getBagCodeByCode(reqVO.getScanContent()) : null;
        if (pallet == null && bag == null) {
            throw exception(WM_MATERIAL_STOCK_NOT_EXISTS);
        }
        // æœªç»‘定生产批次的临时托盘不允许参与销售出库
        if (pallet != null && pallet.getBatchId() == null) {
            throw exception(PRO_PALLET_NOT_BIND_BATCH);
        }
        // ç§å­è´¨æ£€æŒ‰è¢‹ç ç²’度流转后,出库只允许已质检合格的袋码/托盘
        if (pallet != null) {
            List<MesProBagCodeDO> palletBags = bagCodeService.getBagCodeListByPalletId(pallet.getId());
            if (palletBags.stream().anyMatch(b -> !MesProBagCodeQcStatusEnum.QUALIFIED.getStatus().equals(b.getQcStatus()))) {
                throw exception(WM_PRODUCT_SALES_SCAN_PALLET_NOT_ALL_QUALIFIED);
            }
        } else if (!MesProBagCodeQcStatusEnum.QUALIFIED.getStatus().equals(bag.getQcStatus())) {
            throw exception(WM_PRODUCT_SALES_SCAN_BAG_NOT_QUALIFIED);
        }
        Long codeId = pallet != null ? pallet.getId() : bag.getId();
        MesWmProductSalesDetailDO existed = pallet != null
                ? productSalesDetailMapper.selectBySalesIdAndPalletCodeId(reqVO.getSalesId(), codeId)
                : productSalesDetailMapper.selectBySalesIdAndBagCodeId(reqVO.getSalesId(), codeId);
        if (existed != null) {
            return new MesWmProductSalesScanRespVO().setScanType(pallet != null ? "PALLET" : "BAG")
                    .setCodeId(codeId).setScanContent(reqVO.getScanContent()).setAlreadyScanned(true)
                    .setDetailId(existed.getId()).setOutboundQuantity(existed.getQuantity()).setMessage("该码已扫描");
        }
        Long proBatchId = pallet != null ? pallet.getBatchId() : bag.getBatchId();
        MesProBatchDO batch = proBatchService.getBatch(proBatchId);
        if (batch == null) {
            throw exception(WM_TRANSACTION_BATCH_NOT_EXISTS);
        }
        // ç”Ÿäº§æ‰¹æ¬¡æ¡¥æŽ¥ä»“库批次:扫码入库写入的是仓库批次(mes_wm_batch),此处必须用仓库批次 id æŸ¥åº“å­˜
        MesWmBatchDO wmBatch = mesWmBatchService.getOrCreateByProBatch(batch);
        if (wmBatch == null) {
            throw exception(WM_TRANSACTION_BATCH_NOT_EXISTS);
        }
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(reqVO.getSalesId());
        MesWmProductSalesLineDO line = lines.stream().filter(item -> ObjUtil.equal(item.getItemId(), batch.getItemId())).findFirst().orElse(null);
        if (line == null) {
            throw exception(WM_PRODUCT_SALES_DETAIL_ITEM_MISMATCH);
        }
        BigDecimal quantity = reqVO.getQuantity();
        if (quantity == null) {
            quantity = line.getQuantity();
        }
        // åªä»Žåˆæ ¼åº“记录中选库存(同批次暂存库记录不可出库)
        List<MesWmMaterialStockDO> stocks = materialStockService.getQualifiedMaterialStockListByItemAndBatch(batch.getItemId(), wmBatch.getId());
        BigDecimal outboundQuantity = quantity;
        MesWmMaterialStockDO stock = stocks.stream().filter(item -> item.getQuantity().compareTo(outboundQuantity) >= 0).findFirst().orElse(null);
        if (stock == null) {
            throw exception(WM_MATERIAL_STOCK_INSUFFICIENT);
        }
        MesWmProductSalesLineDO line = productSalesLineService.getProductSalesLineListBySalesId(sales.getId()).stream()
                .filter(l -> Objects.equals(l.getItemId(), scope.batch().getItemId())).findFirst().orElse(null);
        if (line == null) throw exception(WM_PRODUCT_SALES_DETAIL_ITEM_MISMATCH);
        BigDecimal scanned = productSalesDetailService.getProductSalesDetailListByLineId(line.getId()).stream()
                .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add);
        if (scanned.add(quantity).compareTo(line.getQuantity()) > 0) {
            throw exception(WM_PRODUCT_SALES_LINE_QUANTITY_INVALID);
        if (line.getQuantity() == null || scanned.add(quantity).compareTo(line.getQuantity()) > 0) throw exception(WM_PRODUCT_SALES_LINE_QUANTITY_INVALID);
        Map<Long, Long> byStock = selected.stream().collect(Collectors.groupingBy(b -> stockBags.get(b.getId()).getMaterialStockId(), Collectors.counting()));
        for (var allocation : byStock.entrySet()) {
            BigDecimal ownStockQty = own.stream().filter(d -> Objects.equals(d.getMaterialStockId(), allocation.getKey()))
                    .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add);
            if (stocks.get(allocation.getKey()).getQuantity().compareTo(ownStockQty.add(BigDecimal.valueOf(allocation.getValue()))) < 0)
                throw exception(WM_MATERIAL_STOCK_INSUFFICIENT);
        }
        MesWmProductSalesDetailDO detail = new MesWmProductSalesDetailDO().setLineId(line.getId())
                .setSalesId(reqVO.getSalesId()).setItemId(batch.getItemId()).setQuantity(quantity)
                .setMaterialStockId(stock.getId()).setBatchId(wmBatch.getId()).setBatchCode(wmBatch.getCode())
                .setWarehouseId(stock.getWarehouseId()).setLocationId(stock.getLocationId()).setAreaId(stock.getAreaId())
                .setBagCodeId(pallet == null ? codeId : null).setPalletCodeId(pallet != null ? codeId : null);
        productSalesDetailMapper.insert(detail);
        return new MesWmProductSalesScanRespVO().setScanType(pallet != null ? "PALLET" : "BAG")
                .setCodeId(codeId).setScanContent(reqVO.getScanContent()).setItemId(batch.getItemId())
                .setBatchId(wmBatch.getId()).setBatchCode(wmBatch.getCode()).setMaterialStockId(stock.getId())
                .setAvailableQuantity(stock.getQuantity()).setScanQuantity(quantity).setOutboundQuantity(quantity)
                .setAlreadyScanned(false).setDetailId(detail.getId()).setWarehouseId(stock.getWarehouseId())
                .setLocationId(stock.getLocationId()).setAreaId(stock.getAreaId()).setMessage("扫码成功");
        Long firstDetail = null;
        for (MesProBagCodeDO bag : selected) {
            MesWmMaterialStockDO stock = stocks.get(stockBags.get(bag.getId()).getMaterialStockId());
            MesWmProductSalesDetailDO detail = new MesWmProductSalesDetailDO().setSalesId(sales.getId()).setLineId(line.getId())
                    .setItemId(scope.batch().getItemId()).setQuantity(BigDecimal.ONE).setBagCodeId(bag.getId())
                    .setPalletCodeId(bag.getPalletId()).setBatchId(wmBatch.getId()).setBatchCode(wmBatch.getCode()).setMaterialStockId(stock.getId())
                    .setWarehouseId(stock.getWarehouseId()).setLocationId(stock.getLocationId()).setAreaId(stock.getAreaId())
                    .setRemark("扫码类型 " + scope.type() + ";来源码 " + reqVO.getScanContent() + ";袋码 " + bag.getCode());
            productSalesDetailMapper.insert(detail);
            if (firstDetail == null) firstDetail = detail.getId();
        }
        return new MesWmProductSalesScanRespVO().setScanType(scope.type()).setCodeId(scope.id()).setScanContent(reqVO.getScanContent())
                .setItemId(scope.batch().getItemId()).setBatchCode(wmBatch.getCode()).setSelectedBagIds(ids)
                .setScanQuantity(quantity).setOutboundQuantity(quantity).setAlreadyScanned(false).setDetailId(firstDetail).setMessage("已按所选袋码加入拣货");
    }
    @Override
@@ -398,7 +475,8 @@
            if (detail.getBagCodeId() != null) {
                bagCodeService.markOutbound(detail.getBagCodeId(), id);
            }
            if (detail.getPalletCodeId() != null) {
            // éƒ¨åˆ†æ‰˜ç›˜æ‹£è´§å¯èƒ½åˆ†ç»™å¤šä¸ªç»é”€å•†ï¼Œä¸èƒ½æŠŠæ•´ä¸ªæ‰˜ç›˜æ ‡è®°ä¸ºæœ¬å•出库。
            if (detail.getPalletCodeId() != null && detail.getBagCodeId() == null) {
                palletService.markOutbound(detail.getPalletCodeId(), id);
            }
        }
@@ -428,7 +506,7 @@
                .setType(MesWmTransactionTypeEnum.OUT.getType()).setItemId(detail.getItemId())
                .setQuantity(detail.getQuantity().negate()) // å‡ºåº“数量为负数
                .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
                .setBagCodeId(detail.getBagCodeId()) // è¢‹ç åž‹æ˜Žç»†å‡ºåº“时置袋码已出库;托盘型明细不逐袋处理
                .setBagCodeId(detail.getBagCodeId()) // æ–°æ‰«ç æ˜Žç»†é€è¢‹å…³è”,执行出库逐袋更新库存状态
                .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
                .setBizType(MesBizTypeConstants.WM_PRODUCT_SALES).setBizId(sales.getId())
                .setBizCode(sales.getCode()).setBizLineId(detail.getLineId())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/stocktaking/task/MesWmStockTakingTaskServiceImpl.java
@@ -295,12 +295,12 @@
            return wmBatch;
        }
        // 2. å‘½ä¸­è¢‹ç 
        MesProBagCodeDO bag = bagCodeMapper.selectByCode(scanCode);
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(scanCode);
        if (bag != null && bag.getBatchId() != null) {
            return bridgeProBatch(bag.getBatchId());
        }
        // 3. å‘½ä¸­æ‰˜ç›˜ç 
        MesProPalletDO pallet = palletMapper.selectByCode(scanCode);
        MesProPalletDO pallet = palletMapper.selectByScanCode(scanCode);
        if (pallet != null && pallet.getBatchId() != null) {
            return bridgeProBatch(pallet.getBatchId());
        }
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/MobileScanAccessTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.scan;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.mes.controller.admin.pro.scan.vo.MesProScanEventPageReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.scan.MesProScanEventDO;
import cn.iocoder.yudao.module.mes.service.pro.scan.MesProScanEventService;
import org.junit.jupiter.api.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class MobileScanAccessTest {
  private final MesProScanEventService service=mock(MesProScanEventService.class);
  private final MesProScanEventController controller=new MesProScanEventController();
  @BeforeEach void setup(){
    SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(7L),new org.springframework.mock.web.MockHttpServletRequest());
    ReflectionTestUtils.setField(controller,"service",service);
  }
  @AfterEach void cleanup(){SecurityContextHolder.clearContext();}
  @Test void overridesForgedCreatorOnOwnPage(){
    MesProScanEventPageReqVO req=new MesProScanEventPageReqVO();req.setCreator("8");
    when(service.getPage(req)).thenReturn(new PageResult<>(List.of(),0L));
    controller.myPage(req);
    assertEquals("7",req.getCreator());verify(service).getPage(req);
  }
  @Test void doesNotExposeAnotherUsersEvent(){
    MesProScanEventDO event=new MesProScanEventDO();event.setCreator("8");
    when(service.getByEventId("other")).thenReturn(event);
    assertNull(controller.myEvent("other").getData());
    event.setCreator("7");event.setEventId("other");
    assertEquals("other",controller.myEvent("other").getData().getEventId());
  }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/BagBusinessCodeTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.*;
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.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeReplaceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.hutool.extra.qrcode.QrCodeUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class BagBusinessCodeTest {
    private final MesProBagCodeServiceImpl service = new MesProBagCodeServiceImpl();
    private final MesProBagCodeMapper bags = mock(MesProBagCodeMapper.class);
    private final MesCodeRedisDAO codes = mock(MesCodeRedisDAO.class);
    private final MesMdmItemService items = mock(MesMdmItemService.class);
    private final MesProBatchMapper batches = mock(MesProBatchMapper.class);
    private final MesProBagCodeReplaceMapper replacements = mock(MesProBagCodeReplaceMapper.class);
    private final MdmUnitMeasureApi units = mock(MdmUnitMeasureApi.class);
    @BeforeEach void setup() {
        ReflectionTestUtils.setField(service, "bagCodeMapper", bags);
        ReflectionTestUtils.setField(service, "codeRedisDao", codes);
        ReflectionTestUtils.setField(service, "mdmItemService", items);
        ReflectionTestUtils.setField(service, "batchMapper", batches);
        ReflectionTestUtils.setField(service, "replaceMapper", replacements);
        ReflectionTestUtils.setField(service, "unitMeasureApi", units);
        when(items.validateItemExists(1L)).thenReturn(new MesMdmItemDO().setId(1L).setUnitMeasureId(2L));
        AtomicLong sequence = new AtomicLong(1000000000000000L);
        when(codes.generate()).thenAnswer(invocation -> String.valueOf(sequence.incrementAndGet()));
        when(codes.allocateBagNoStart(anyInt(), any())).thenReturn(1);
    }
    @Test void rawGenerationCreatesDistinctPairsInSameRecords() {
        MesProBagCodeRawGenerateReqVO req = new MesProBagCodeRawGenerateReqVO();
        req.setItemId(1L); req.setCount(2);
        service.generateRawBagCodes(req);
        assertDistinctPairs(2);
    }
    @Test void dataPackageGenerationCreatesBusinessCodes() {
        when(units.getUnitMeasure(2L)).thenReturn(CommonResult.success(new MdmUnitMeasureRespDTO().setType("吨")));
        MesProBagCodeGenerateReqVO req = new MesProBagCodeGenerateReqVO();
        req.setItemId(1L); req.setBagCount(2);
        service.generateBagCodes(req);
        assertDistinctPairs(2);
    }
    @Test @SuppressWarnings({"unchecked", "rawtypes"}) void automaticPalletCarriesVariety() {
        var pallets=mock(cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper.class);
        ReflectionTestUtils.setField(service,"palletMapper",pallets);
        when(units.getUnitMeasure(2L)).thenReturn(CommonResult.success(new MdmUnitMeasureRespDTO().setType("2千克")));
        var req=new MesProBagCodeGenerateReqVO();req.setItemId(1L);req.setPalletCount(1);
        service.generateBagCodes(req);
        ArgumentCaptor<Collection<cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO>> saved=ArgumentCaptor.forClass((Class)Collection.class);
        verify(pallets).insertBatch(saved.capture());assertEquals(1L,saved.getValue().iterator().next().getItemId());
    }
    @Test void replacementIssuesNewPairInsteadOfReusingOldBusinessCode() {
        when(batches.selectByIdForUpdate(2L)).thenReturn(new MesProBatchDO().setId(2L).setStatus(20));
        when(bags.selectListByIdsForUpdate(List.of(1L))).thenReturn(List.of(new MesProBagCodeDO()
                .setId(1L).setBatchId(2L).setStatus(10).setCode("9999999999999999").setBusinessCode("8888888888888888")));
        when(bags.cancelById(eq(1L), eq(10), eq(30), any(), any())).thenReturn(1);
        MesProBagCodeReplaceReqVO req = new MesProBagCodeReplaceReqVO();
        req.setBatchId(2L); req.setOldIds(List.of(1L)); req.setReason("破损");
        service.replaceBagCodes(req);
        assertDistinctPairs(1);
    }
    @Test void supplementationLocksAndNeverChangesExistingCode() {
        when(bags.selectListByIdsForUpdate(List.of(1L, 2L))).thenReturn(List.of(
                new MesProBagCodeDO().setId(1L).setStatus(10),
                new MesProBagCodeDO().setId(2L).setStatus(20).setBusinessCode("8888888888888888")));
        assertEquals(1, service.generateBusinessCodes(List.of(2L, 1L, 1L)));
        ArgumentCaptor<MesProBagCodeDO> update = ArgumentCaptor.forClass(MesProBagCodeDO.class);
        verify(bags).updateById(update.capture());
        assertEquals(1L, update.getValue().getId());
        assertNull(update.getValue().getCode());
        assertNotNull(update.getValue().getBusinessCode());
        when(bags.selectListByIdsForUpdate(List.of(1L))).thenReturn(List.of(update.getValue().setStatus(10)));
        assertEquals(0, service.generateBusinessCodes(List.of(1L)));
        verify(codes, times(1)).generate();
    }
    @Test void canceledOrMissingBagCannotBeSupplemented() {
        when(bags.selectListByIdsForUpdate(List.of(1L))).thenReturn(List.of(new MesProBagCodeDO().setId(1L).setStatus(30)));
        assertThrows(ServiceException.class, () -> service.generateBusinessCodes(List.of(1L)));
        assertThrows(ServiceException.class, () -> service.generateBusinessCodes(List.of(2L)));
        verifyNoInteractions(codes);
        verify(bags, never()).updateById(any(MesProBagCodeDO.class));
    }
    @Test void publicLookupUsesTraceColumnAndInternalLookupUsesBoth() {
        service.getBagCodeByCode("1111111111111111");
        verify(bags).selectByCode("1111111111111111");
        service.getBagCodeByScanCode("2222222222222222");
        verify(bags).selectByScanCode("2222222222222222");
    }
    @Test void businessQrEncodesStoredValueAndRejectsMissingCode() {
        var label = MesProQrLabelBuilder.buildBagBusinessCodeLabel(new MesProBagCodeDO()
                .setCode("9999999999999999").setBusinessCode("8888888888888888"));
        assertEquals("8888888888888888", QrCodeUtil.decode(QrCodeImageUtils.createQrLabel(label.getCode(), label.getFileName())));
        assertThrows(ServiceException.class, () -> MesProQrLabelBuilder.buildBagBusinessCodeLabel(new MesProBagCodeDO()));
    }
    @SuppressWarnings({"unchecked", "rawtypes"})
    private void assertDistinctPairs(int count) {
        ArgumentCaptor<Collection<MesProBagCodeDO>> saved = ArgumentCaptor.forClass((Class) Collection.class);
        verify(bags).insertBatch(saved.capture());
        assertEquals(count, saved.getValue().size());
        var values = new HashSet<String>();
        for (MesProBagCodeDO bag : saved.getValue()) {
            assertTrue(bag.getBusinessCode().matches("[0-9]{16}"));
            assertTrue(values.add(bag.getCode())); assertTrue(values.add(bag.getBusinessCode()));
        }
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/PalletBatchBusinessCodeTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,119 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.pro.pallet.vo.*;
import cn.iocoder.yudao.module.mes.controller.admin.pro.batch.vo.MesProBatchPreGenerateReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletServiceImpl;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchServiceImpl;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Collection;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PalletBatchBusinessCodeTest {
    private final MesProPalletServiceImpl palletService = new MesProPalletServiceImpl();
    private final MesProBatchServiceImpl batchService = new MesProBatchServiceImpl();
    private final MesProPalletMapper pallets = mock(MesProPalletMapper.class);
    private final MesProBatchMapper batches = mock(MesProBatchMapper.class);
    private final MesProBagCodeMapper bags = mock(MesProBagCodeMapper.class);
    private final MesCodeRedisDAO codes = mock(MesCodeRedisDAO.class);
    @BeforeEach void setup() {
        ReflectionTestUtils.setField(palletService, "palletMapper", pallets);
        ReflectionTestUtils.setField(palletService, "batchMapper", batches);
        ReflectionTestUtils.setField(palletService, "bagCodeMapper", bags);
        ReflectionTestUtils.setField(palletService, "codeRedisDao", codes);
        ReflectionTestUtils.setField(palletService,"integrityService",mock(cn.iocoder.yudao.module.mes.service.trace.integrity.MesTraceIntegrityService.class));
        ReflectionTestUtils.setField(batchService, "batchMapper", batches);
        ReflectionTestUtils.setField(batchService, "codeRedisDao", codes);
        AtomicLong sequence = new AtomicLong(1000000000000000L);
        when(codes.generate()).thenAnswer(invocation -> String.valueOf(sequence.incrementAndGet()));
    }
    @Test void preGeneratedPalletsAndBatchesAlreadyHaveIndependentBusinessCodes() {
        var palletReq = new MesProPalletPreGenerateReqVO(); palletReq.setCount(2);
        var batchReq = new MesProBatchPreGenerateReqVO(); batchReq.setCount(2);
        var values = new java.util.HashSet<String>();
        palletService.preGeneratePallet(palletReq).forEach(record -> {
            assertTrue(values.add(record.getTempCode())); assertTrue(values.add(record.getBusinessCode()));
        });
        batchService.preGenerateBatch(batchReq).forEach(record -> {
            assertTrue(values.add(record.getTempCode())); assertTrue(values.add(record.getBusinessCode()));
        });
        assertEquals(8, values.size());
        assertTrue(values.stream().allMatch(code -> code.matches("[0-9]{16}")));
        verify(pallets).insertBatch(anyCollection());
        verify(batches, times(2)).insert(any(MesProBatchDO.class));
        // é¢„生成只能写入新记录,不应读取或截取数据库中的旧临时码。
        verifyNoMoreInteractions(pallets, batches);
    }
    @Test @SuppressWarnings({"unchecked", "rawtypes"})
    void directPalletCreationCreatesBothCodes() {
        when(batches.selectById(2L)).thenReturn(new MesProBatchDO().setId(2L).setCode("batch").setStatus(20));
        var req = new MesProPalletBatchCreateReqVO(); req.setBatchId(2L); req.setCount(1);
        palletService.batchCreatePallet(req);
        ArgumentCaptor<Collection<MesProPalletDO>> saved = ArgumentCaptor.forClass((Class) Collection.class);
        verify(pallets).insertBatch(saved.capture());
        var pallet = saved.getValue().iterator().next();
        assertNotNull(pallet.getBusinessCode()); assertNotEquals(pallet.getCode(), pallet.getBusinessCode());
    }
    @Test void batchBindingKeepsBusinessCodeAndCopiesActualFormalCodeToBags() {
        var pallet = new MesProPalletDO().setId(1L).setItemId(3L).setTempCode("1111111111111111").setBusinessCode("2222222222222222").setStatus(10);
        when(pallets.selectByIdForUpdate(1L)).thenReturn(pallet);
        when(batches.selectByIdForUpdate(2L)).thenReturn(new MesProBatchDO().setId(2L).setCode("BATCH").setStatus(20));
        var req = new MesProPalletBindBatchReqVO(); req.setBatchId(2L); req.setIds(List.of(1L));
        palletService.bindBatch(req);
        var update = ArgumentCaptor.forClass(MesProPalletDO.class);
        verify(pallets).updateById(update.capture());
        assertNull(update.getValue().getBusinessCode()); // MyBatis ignores null fields, so stable business code is retained.
        assertEquals("2222222222222222", pallet.getBusinessCode());
        verify(bags).updatePalletCodeByPalletId(1L, update.getValue().getCode());
    }
    @Test void supplementationNeverChangesPreviouslyIssuedPalletOrBatchCode() {
        when(pallets.selectByIdForUpdate(1L)).thenReturn(new MesProPalletDO().setId(1L).setStatus(10).setBusinessCode("1111111111111111"));
        when(batches.selectByIdForUpdate(2L)).thenReturn(new MesProBatchDO().setId(2L).setStatus(20).setBusinessCode("2222222222222222"));
        assertEquals(0, palletService.generateBusinessCodes(List.of(1L, 1L)));
        assertEquals(0, batchService.generateBusinessCodes(List.of(2L)));
        verifyNoInteractions(codes);
        verify(pallets, never()).updateById(any(MesProPalletDO.class));
        verify(batches, never()).updateById(any(MesProBatchDO.class));
    }
    @Test void supplementationRejectsCanceledAndMissingBeforeWritingAnyCode() {
        when(pallets.selectByIdForUpdate(1L)).thenReturn(new MesProPalletDO().setId(1L).setStatus(10));
        when(pallets.selectByIdForUpdate(2L)).thenReturn(new MesProPalletDO().setId(2L).setStatus(20));
        when(batches.selectByIdForUpdate(1L)).thenReturn(new MesProBatchDO().setId(1L).setStatus(40));
        assertThrows(ServiceException.class, () -> palletService.generateBusinessCodes(List.of(1L, 2L)));
        assertThrows(ServiceException.class, () -> batchService.generateBusinessCodes(List.of(1L)));
        assertThrows(ServiceException.class, () -> batchService.generateBusinessCodes(List.of(3L)));
        verifyNoInteractions(codes);
        verify(pallets, never()).updateById(any(MesProPalletDO.class));
    }
    @Test void publicQueryRemainsSeparateFromInternalBusinessQuery() {
        palletService.getPalletByCode("old"); batchService.getBatchByCode("old");
        verify(pallets).selectByCode("old"); verify(pallets).selectByTempCode("old");
        verify(batches).selectByCode("old"); verify(batches).selectByTempCode("old");
        palletService.getPalletByScanCode("business"); batchService.getBatchByScanCode("business");
        verify(pallets).selectByScanCode("business"); verify(batches).selectByScanCode("business");
        assertEquals("1111111111111111", MesProQrLabelBuilder.buildPalletBusinessCodeLabel(
                new MesProPalletDO().setBusinessCode("1111111111111111")).getCode());
        assertEquals("2222222222222222", MesProQrLabelBuilder.buildBatchBusinessCodeLabel(
                new MesProBatchDO().setBusinessCode("2222222222222222")).getCode());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/PalletItemBindingTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.pro.pallet.vo.MesProPalletBindBatchReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletServiceImpl;
import org.junit.jupiter.api.*;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PalletItemBindingTest {
    final MesProPalletServiceImpl service=new MesProPalletServiceImpl();
    final MesProPalletMapper pallets=mock(MesProPalletMapper.class);
    final MesProBatchMapper batches=mock(MesProBatchMapper.class);
    final MesProBagCodeMapper bags=mock(MesProBagCodeMapper.class);
    final MesCodeRedisDAO codes=mock(MesCodeRedisDAO.class);
    MesProBatchDO batch;
    MesProPalletBindBatchReqVO req;
    @BeforeEach void setup(){
        ReflectionTestUtils.setField(service,"palletMapper",pallets);ReflectionTestUtils.setField(service,"batchMapper",batches);
        ReflectionTestUtils.setField(service,"bagCodeMapper",bags);ReflectionTestUtils.setField(service,"codeRedisDao",codes);
        ReflectionTestUtils.setField(service,"integrityService",mock(cn.iocoder.yudao.module.mes.service.trace.integrity.MesTraceIntegrityService.class));
        batch=new MesProBatchDO().setId(8L).setCode("BATCH").setStatus(20);
        when(batches.selectByIdForUpdate(8L)).thenReturn(batch);when(codes.generate()).thenReturn("1234567890123456");
        req=new MesProPalletBindBatchReqVO();req.setBatchId(8L);req.setIds(List.of(1L,2L));
        when(pallets.selectByIdForUpdate(1L)).thenReturn(new MesProPalletDO().setId(1L).setItemId(5L).setStatus(10));
        when(pallets.selectByIdForUpdate(2L)).thenReturn(new MesProPalletDO().setId(2L).setItemId(5L).setStatus(10));
    }
    @Test void sameVarietyFillsBatchAndPallets(){
        assertEquals(2,service.bindBatch(req));
        var saved=ArgumentCaptor.forClass(MesProBatchDO.class);verify(batches).updateById(saved.capture());assertEquals(5L,saved.getValue().getItemId());
        var savedPallet=ArgumentCaptor.forClass(MesProPalletDO.class);verify(pallets,times(2)).updateById(savedPallet.capture());
        assertTrue(savedPallet.getAllValues().stream().allMatch(p->Long.valueOf(5).equals(p.getItemId())));
    }
    @Test void mixedPalletVarietiesRejectBeforeAnyWrite(){
        when(pallets.selectByIdForUpdate(2L)).thenReturn(new MesProPalletDO().setId(2L).setItemId(6L));assertRejected();
    }
    @Test void existingBatchVarietyCannotBeOverwritten(){batch.setItemId(6L);assertRejected();}
    @Test void existingBatchBagsAlsoConstrainVariety(){when(bags.selectDistinctItemIdsByBatchId(8L)).thenReturn(List.of(6L));assertRejected();}
    @Test void palletMetadataCannotHideMixedBagVarieties(){
        when(bags.selectListByPalletId(1L)).thenReturn(List.of(new MesProBagCodeDO().setItemId(6L).setStatus(10)));assertRejected();
    }
    @Test void missingVarietyCannotBeBound(){when(pallets.selectByIdForUpdate(2L)).thenReturn(new MesProPalletDO().setId(2L));assertRejected();}
    @Test void historicalPalletInfersVarietyFromBags(){
        when(pallets.selectByIdForUpdate(1L)).thenReturn(new MesProPalletDO().setId(1L));
        when(bags.selectListByPalletId(1L)).thenReturn(List.of(new MesProBagCodeDO().setItemId(5L).setStatus(10),new MesProBagCodeDO().setItemId(6L).setStatus(30)));
        assertEquals(2,service.bindBatch(req));
    }
    @Test void batchCreateCannotForgeVarietyDifferentFromSelectedPallet(){
        var batchService=new cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchServiceImpl();
        ReflectionTestUtils.setField(batchService,"palletMapper",pallets);ReflectionTestUtils.setField(batchService,"bagCodeMapper",bags);
        ReflectionTestUtils.setField(batchService,"adminUserApi",mock(cn.iocoder.yudao.module.system.api.user.AdminUserApi.class));
        var lines=mock(cn.iocoder.yudao.module.mes.service.pro.line.MesProLineService.class);
        ReflectionTestUtils.setField(batchService,"lineService",lines);
        when(lines.validateLineExistsAndEnable(1L)).thenReturn(new cn.iocoder.yudao.module.mes.dal.dataobject.pro.line.MesProLineDO().setId(1L));
        var request=new cn.iocoder.yudao.module.mes.controller.admin.pro.batch.vo.MesProBatchSaveReqVO();
        request.setLineId(1L);request.setItemId(99L);request.setPalletIds(List.of(1L));request.setWorkers(List.of());
        assertThrows(ServiceException.class,()->batchService.createBatch(request));
    }
    @Test void editingBatchCannotChangeItsExistingVariety(){
        var batchService=new cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchServiceImpl();
        ReflectionTestUtils.setField(batchService,"batchMapper",batches);ReflectionTestUtils.setField(batchService,"palletMapper",pallets);
        ReflectionTestUtils.setField(batchService,"bagCodeMapper",bags);
        ReflectionTestUtils.setField(batchService,"adminUserApi",mock(cn.iocoder.yudao.module.system.api.user.AdminUserApi.class));
        batch.setItemId(5L);
        var request=new cn.iocoder.yudao.module.mes.controller.admin.pro.batch.vo.MesProBatchSaveReqVO();
        request.setId(8L);request.setItemId(99L);request.setWorkers(List.of());
        assertThrows(ServiceException.class,()->batchService.updateBatch(request));verify(batches,never()).updateById(any(MesProBatchDO.class));
    }
    void assertRejected(){assertThrows(ServiceException.class,()->service.bindBatch(req));verify(pallets,never()).updateById(any(MesProPalletDO.class));verify(batches,never()).updateById(any(MesProBatchDO.class));verifyNoInteractions(codes);}
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/scan/InboundCompletionSqlTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.mes.service.pro.scan;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import org.apache.ibatis.annotations.Update;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** æ‰§è¡Œ Mapper ä¸­çš„实际 SQL,验证混扫时状态与袋数的结果。 */
class InboundCompletionSqlTest {
    private JdbcTemplate db;
    private NamedParameterJdbcTemplate named;
    @BeforeEach void setup() {
        JdbcDataSource source = new JdbcDataSource();
        source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
        db = new JdbcTemplate(source); named = new NamedParameterJdbcTemplate(source);
        db.execute("CREATE TABLE mes_pro_batch(id BIGINT PRIMARY KEY,status INT,deleted INT DEFAULT 0,inbound_time TIMESTAMP,inbound_event_id BIGINT,inbound_device_code VARCHAR(64),inbound_line_code VARCHAR(64),inbound_bag_quantity INT,update_time TIMESTAMP)");
        db.execute("CREATE TABLE mes_pro_pallet(id BIGINT PRIMARY KEY,batch_id BIGINT,status INT,deleted INT DEFAULT 0,inbound_time TIMESTAMP,inbound_event_id BIGINT,update_time TIMESTAMP)");
        db.execute("CREATE TABLE mes_pro_bag_code(id BIGINT PRIMARY KEY,batch_id BIGINT,pallet_id BIGINT,status INT,deleted INT DEFAULT 0,inbound_time TIMESTAMP,inbound_event_id BIGINT,inbound_device_code VARCHAR(64),inbound_line_code VARCHAR(64),update_time TIMESTAMP)");
        db.update("INSERT INTO mes_pro_batch(id,status) VALUES(1,20)");
        db.update("INSERT INTO mes_pro_pallet(id,batch_id,status) VALUES(1,1,10),(2,1,10)");
        db.update("INSERT INTO mes_pro_bag_code(id,batch_id,pallet_id,status) VALUES(1,1,1,20),(2,1,1,20),(3,1,2,20),(4,1,2,30)");
    }
    private int run(Class<?> mapper, String method, Map<String,Object> values) {
        var target = java.util.Arrays.stream(mapper.getMethods()).filter(m -> m.getName().equals(method)).findFirst().orElseThrow();
        String sql = String.join(" ", target.getAnnotation(Update.class).value()).replaceAll("#\\{(\\w+)\\}", ":$1");
        return named.update(sql, values);
    }
    private Map<String,Object> args(Long id) {
        Map<String,Object> values = new HashMap<>();
        values.put("id",id);values.put("batchId",1L);values.put("palletId",id);
        values.put("eventId",10L);values.put("deviceCode","PDA");values.put("lineCode","L1");
        return values;
    }
    private void refresh() {
        run(MesProPalletMapper.class,"markCompletedInboundByBatchId",args(1L));
        run(MesProBatchMapper.class,"refreshInboundProgress",args(1L));
    }
    private int batchValue(String column) { return db.queryForObject("SELECT " + column + " FROM mes_pro_batch WHERE id=1",Integer.class); }
    @Test void bagThenPalletThenLastBagCompletesOnlyAtTheEnd() {
        run(MesProBagCodeMapper.class,"markInbound",args(1L));refresh();
        assertEquals(20,batchValue("status")); assertEquals(1,batchValue("inbound_bag_quantity"));
        assertNull(db.queryForObject("SELECT inbound_event_id FROM mes_pro_pallet WHERE id=1",Long.class));
        assertEquals(1,run(MesProBagCodeMapper.class,"markInboundByPalletId",args(1L)));
        refresh();assertEquals(20,batchValue("status"));assertEquals(2,batchValue("inbound_bag_quantity"));
        assertEquals(10L,db.queryForObject("SELECT inbound_event_id FROM mes_pro_pallet WHERE id=1",Long.class));
        run(MesProBagCodeMapper.class,"markInbound",args(3L));refresh();
        assertEquals(30,batchValue("status"));assertEquals(3,batchValue("inbound_bag_quantity"));
        assertEquals(10L,db.queryForObject("SELECT inbound_event_id FROM mes_pro_pallet WHERE id=2",Long.class));
        assertNull(db.queryForObject("SELECT inbound_event_id FROM mes_pro_bag_code WHERE id=4",Long.class));
        assertEquals(0,run(MesProBagCodeMapper.class,"markInboundByBatchId",args(1L)));
        refresh();assertEquals(3,batchValue("inbound_bag_quantity"));
    }
    @Test void wholeBatchExcludesCanceledAndDeletedBags() {
        db.update("UPDATE mes_pro_bag_code SET deleted=1 WHERE id=2");
        assertEquals(2,run(MesProBagCodeMapper.class,"markInboundByBatchId",args(1L)));
        refresh();assertEquals(30,batchValue("status"));assertEquals(2,batchValue("inbound_bag_quantity"));
    }
    @Test void emptyBatchDoesNotCompleteAndCanceledBatchDoesNotChange() {
        db.update("DELETE FROM mes_pro_bag_code");refresh();assertEquals(20,batchValue("status"));
        db.update("UPDATE mes_pro_batch SET status=40");refresh();assertEquals(40,batchValue("status"));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/scan/MobileScanRetryTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.mes.service.pro.scan;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.mes.controller.admin.pro.scan.vo.MesProScanEventCreateReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.scan.MesProScanEventDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.scan.MesProScanEventMapper;
import org.junit.jupiter.api.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class MobileScanRetryTest {
 private final MesProScanEventMapper mapper=mock(MesProScanEventMapper.class);
 private final MesProScanEventServiceImpl service=new MesProScanEventServiceImpl();
 private MesProScanEventCreateReqVO req;
 private MesProScanEventDO existing;
 @BeforeEach void setup(){
   SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(7L),new org.springframework.mock.web.MockHttpServletRequest());
   ReflectionTestUtils.setField(service,"eventMapper",mapper);
   req=new MesProScanEventCreateReqVO();req.setEventId("original");req.setBagCode("123");req.setQuantity(new BigDecimal("1.00"));req.setLocationId(2L);req.setAreaId(3L);
   existing=new MesProScanEventDO().setEventId("original").setBagCode("123").setScanType("BAG").setQuantity(BigDecimal.ONE).setLocationId(2L).setAreaId(3L).setStatus("SUCCESS");
   existing.setCreator("7");
   when(mapper.selectByEventIdForUpdate("original")).thenReturn(existing);
 }
 @AfterEach void cleanup(){SecurityContextHolder.clearContext();}
 @Test void sameOperationReturnsOriginalWithoutAnotherInventoryWrite(){
   assertSame(existing,service.createInbound(req));verifyNoMoreInteractionsAfterLookup();
 }
 @Test void conflictNeverOverwritesOriginalSuccess(){
   req.setAreaId(4L);
   assertThrows(ServiceException.class,()->service.createInbound(req));
   assertEquals("SUCCESS",existing.getStatus());assertNull(existing.getErrorCode());
   verifyNoMoreInteractionsAfterLookup();
 }
 @Test void anotherUserCannotReplayOriginal(){
   existing.setCreator("8");
   assertThrows(ServiceException.class,()->service.createInbound(req));verifyNoMoreInteractionsAfterLookup();
 }
 @Test void changedBagQuantityCannotReuseOriginalOperation(){
   req.setQuantity(BigDecimal.TEN);
   assertThrows(ServiceException.class,()->service.createInbound(req));verifyNoMoreInteractionsAfterLookup();
 }
 @Test void palletReplayKeepsServerCalculatedQuantity(){
   req.setScanType("PALLET");existing.setScanType("PALLET").setQuantity(BigDecimal.TEN);
   assertSame(existing,service.createInbound(req));assertEquals(BigDecimal.TEN,existing.getQuantity());
   verifyNoMoreInteractionsAfterLookup();
 }
 private void verifyNoMoreInteractionsAfterLookup(){verify(mapper).selectByEventIdForUpdate("original");verifyNoMoreInteractions(mapper);}
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/scan/MobileScanValidityTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,130 @@
package cn.iocoder.yudao.module.mes.service.pro.scan;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.pro.scan.vo.MesProScanEventCreateReqVO;
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.mes.dal.dataobject.pro.scan.MesProScanEventDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.scan.MesProScanEventMapper;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class MobileScanValidityTest {
 private final MesProScanEventServiceImpl service=new MesProScanEventServiceImpl();
 private final MesProScanEventMapper events=mock(MesProScanEventMapper.class);
 private final MesProBagCodeMapper bags=mock(MesProBagCodeMapper.class);
 private final MesProPalletMapper pallets=mock(MesProPalletMapper.class);
 private final MesProBatchMapper batches=mock(MesProBatchMapper.class);
 private final MesWmTransactionService transactions=mock(MesWmTransactionService.class);
 private MesProScanEventCreateReqVO req;
 @BeforeEach void setup(){
   ReflectionTestUtils.setField(service,"eventMapper",events);
   ReflectionTestUtils.setField(service,"bagCodeMapper",bags);
   ReflectionTestUtils.setField(service,"palletMapper",pallets);
   ReflectionTestUtils.setField(service,"batchMapper",batches);
   ReflectionTestUtils.setField(service,"transactionService",transactions);
   req=new MesProScanEventCreateReqVO();req.setEventId("new");req.setBagCode("123");
   req.setQuantity(BigDecimal.ONE);req.setLocationId(2L);req.setAreaId(3L);
 }
 @Test void canceledBagDoesNotCreateStock(){
   when(bags.selectByScanCodeForUpdate("123")).thenReturn(new MesProBagCodeDO().setStatus(30));
   assertFailed("MES_SCAN_BAG_CANCELED");
 }
 @Test void canceledPalletDoesNotCreateStock(){
   req.setScanType("PALLET");
   when(pallets.selectByScanCodeForUpdate("123")).thenReturn(new MesProPalletDO().setStatus(20));
   assertFailed("MES_SCAN_PALLET_CANCELED");
 }
 @Test void canceledBatchDoesNotCreateStock(){
   req.setScanType("BATCH");
   when(batches.selectByScanCodeForUpdate("123")).thenReturn(new MesProBatchDO().setId(2L).setStatus(40).setBagQuantity(1));
   assertFailed("MES_SCAN_BATCH_CANCELED");
 }
 @Test void bagInCanceledBatchDoesNotCreateStock(){
   when(bags.selectByScanCodeForUpdate("123")).thenReturn(new MesProBagCodeDO().setId(1L).setBatchId(2L).setStatus(20));
   when(batches.selectByIdForUpdate(2L)).thenReturn(new MesProBatchDO().setId(2L).setStatus(40));
   assertFailed("MES_SCAN_BATCH_CANCELED");
 }
 @Test void invalidTypeCannotFallThroughToBagInbound(){
   req.setScanType("UNKNOWN");
   assertThrows(ServiceException.class,()->service.createInbound(req));
   verifyNoInteractions(events,bags,pallets,batches,transactions);
 }
 @Test void businessScanWritesCanonicalTraceCodeAndSameBagId(){
   req.setBagCode("8888888888888888");
   when(bags.selectByScanCodeForUpdate(req.getBagCode())).thenReturn(new MesProBagCodeDO()
       .setId(1L).setCode("9999999999999999").setBusinessCode(req.getBagCode()).setBatchId(2L).setStatus(20));
   when(batches.selectByIdForUpdate(2L)).thenReturn(new MesProBatchDO().setId(2L).setStatus(20).setCode("BATCH").setItemId(3L));
   var warehouse=mock(cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService.class);
   var wmBatch=mock(cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService.class);
   ReflectionTestUtils.setField(service,"warehouseService",warehouse);
   ReflectionTestUtils.setField(service,"mesWmBatchService",wmBatch);
   when(warehouse.getWarehouseByCode(anyString())).thenReturn(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO().setId(4L));
   when(wmBatch.getOrCreateByProBatch(any())).thenReturn(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO().setId(5L));
   var result=service.createInbound(req);
   assertEquals("SUCCESS",result.getStatus());
   var tx=org.mockito.ArgumentCaptor.forClass(cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO.class);
   verify(transactions).createTransaction(tx.capture());
   assertEquals(1L,tx.getValue().getBagCodeId());
   assertEquals("9999999999999999",tx.getValue().getBagCode());
   assertEquals("8888888888888888",result.getBagCode());
 }
 @Test void palletBusinessScanUsesActualBagCountAndOriginalBatch(){
   req.setScanType("PALLET");req.setBagCode("7777777777777777");
   when(pallets.selectByScanCodeForUpdate(req.getBagCode())).thenReturn(new MesProPalletDO().setId(7L).setBatchId(2L).setStatus(0).setBagCount(25));
   when(bags.selectPendingInboundCountByPalletId(7L)).thenReturn(25);
   when(batches.selectByIdForUpdate(2L)).thenReturn(new MesProBatchDO().setId(2L).setStatus(20).setCode("BATCH").setItemId(3L));
   assertWholeInbound(7L,25);
   verify(pallets).markInbound(eq(7L),any());
 }
 @Test void batchBusinessScanUsesActualBagCountAndOriginalBatch(){
   req.setScanType("BATCH");req.setBagCode("6666666666666666");
   when(batches.selectByScanCodeForUpdate(req.getBagCode())).thenReturn(new MesProBatchDO().setId(2L).setStatus(20).setCode("BATCH").setItemId(3L).setBagQuantity(50));
   when(bags.selectPendingInboundCountByBatchId(2L)).thenReturn(50);
   assertWholeInbound(2L,50);
   verify(batches).refreshInboundProgress(eq(2L),any(),any(),any());
 }
 @Test void partlyInboundPalletWritesOnlyPendingBags(){
   req.setScanType("PALLET");
   when(pallets.selectByScanCodeForUpdate("123")).thenReturn(new MesProPalletDO().setId(7L).setBatchId(2L).setStatus(10).setBagCount(25));
   when(batches.selectByIdForUpdate(2L)).thenReturn(new MesProBatchDO().setId(2L).setStatus(20).setCode("BATCH").setItemId(3L));
   when(bags.selectPendingInboundCountByPalletId(7L)).thenReturn(24);
   assertWholeInbound(7L,24);
 }
 @Test void partlyInboundBatchIsNotRejectedByEarlierBagEvent(){
   req.setScanType("BATCH");
   when(batches.selectByScanCodeForUpdate("123")).thenReturn(new MesProBatchDO().setId(2L).setStatus(20).setInboundEventId(9L).setCode("BATCH").setItemId(3L).setBagQuantity(25));
   when(bags.selectPendingInboundCountByBatchId(2L)).thenReturn(24);
   assertWholeInbound(2L,24);
 }
 @Test void fullyInboundBatchDoesNotCreateAnotherStockTransaction(){
   req.setScanType("BATCH");
   when(batches.selectByScanCodeForUpdate("123")).thenReturn(new MesProBatchDO().setId(2L).setStatus(30).setCode("BATCH"));
   assertFailed("MES_SCAN_NO_PENDING_BAGS");
 }
 private void assertWholeInbound(Long targetId,int quantity){
   var warehouse=mock(cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService.class);
   var wmBatch=mock(cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService.class);
   ReflectionTestUtils.setField(service,"warehouseService",warehouse);
   ReflectionTestUtils.setField(service,"mesWmBatchService",wmBatch);
   when(warehouse.getWarehouseByCode(anyString())).thenReturn(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO().setId(4L));
   when(wmBatch.getOrCreateByProBatch(any())).thenReturn(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO().setId(5L));
   var result=service.createInbound(req);assertEquals("SUCCESS",result.getStatus());
   assertEquals(req.getBagCode(),result.getBagCode());assertEquals(BigDecimal.valueOf(quantity),result.getQuantity());
   var tx=org.mockito.ArgumentCaptor.forClass(cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO.class);
   verify(transactions).createTransaction(tx.capture());assertEquals(targetId,tx.getValue().getBizLineId());
   assertEquals("BATCH",tx.getValue().getBatchCode());assertEquals(BigDecimal.valueOf(quantity),tx.getValue().getQuantity());
 }
 private void assertFailed(String code){
   MesProScanEventDO result=service.createInbound(req);
   assertEquals("FAILED",result.getStatus());assertEquals(code,result.getErrorCode());
   verify(events).insert(result);verify(events).updateById(result);verifyNoInteractions(transactions);
 }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityGroupTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.*;
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.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.MesQcSeedQualityMapper;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SeedQualityGroupTest {
    private final MesQcSeedQualityServiceImpl service=spy(new MesQcSeedQualityServiceImpl());
    private final MesProBagCodeMapper bags=mock(MesProBagCodeMapper.class);
    private final MesProBatchMapper batches=mock(MesProBatchMapper.class);
    private final MesProPalletMapper pallets=mock(MesProPalletMapper.class);
    private final MesQcSeedQualityMapper qualities=mock(MesQcSeedQualityMapper.class);
    private final MesProBagCodeDO first=bag(1L),second=bag(2L),canceled=bag(3L).setStatus(30);
    private MesQcSeedQualityGroupSubmitReqVO req;
    private MesProBagCodeDO bag(Long id) {return new MesProBagCodeDO().setId(id).setCode("B"+id).setBatchId(10L).setPalletId(20L).setStatus(20).setInboundEventId(9L);}
    @BeforeEach void setup() {
        ReflectionTestUtils.setField(service,"bagCodeMapper",bags);ReflectionTestUtils.setField(service,"batchMapper",batches);
        ReflectionTestUtils.setField(service,"palletMapper",pallets);ReflectionTestUtils.setField(service,"qualityMapper",qualities);
        var batch=new MesProBatchDO().setId(10L).setCode("BATCH").setBusinessCode("BATCH-BUS").setStatus(30);
        when(batches.selectByScanCode(anyString())).thenReturn(batch);when(batches.selectByScanCodeForUpdate(anyString())).thenReturn(batch);
        var pallet=new MesProPalletDO().setId(20L).setCode("PALLET").setBusinessCode("PALLET-BUS").setBatchCode("BATCH").setBatchId(10L).setStatus(10);
        when(pallets.selectByScanCode(anyString())).thenReturn(pallet);when(pallets.selectByScanCodeForUpdate(anyString())).thenReturn(pallet);
        when(bags.selectListByBatchId(10L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByBatchIdForUpdate(10L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByPalletId(20L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByPalletIdForUpdate(20L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByIdsForUpdate(any())).thenReturn(List.of(first,second));
        req=new MesQcSeedQualityGroupSubmitReqVO();req.setCode("BATCH-BUS");req.setScanType("BATCH");req.setBagIds(List.of(1L,2L));
        req.setQualifiedWarehouseId(8L);req.setIndicators(List.of());
        doReturn(101L).when(service).submit(any(MesQcSeedQualitySubmitReqVO.class));
    }
    @Test void batchPreviewCountsEffectiveInspectedAndPendingBags() {
        when(qualities.selectListByCodes(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").build()));
        var result=service.resolveScan("BATCH-BUS","BATCH");assertEquals(2,result.get("totalCount"));
        assertEquals(List.of(1L),result.get("completedBagIds"));assertEquals(List.of(2L),result.get("pendingBagIds"));
        verify(service,never()).submit(any());
    }
    @Test void palletProcessesOnlyConfirmedUninspectedBags() {
        req.setScanType("PALLET");req.setCode("PALLET-BUS");req.setBagIds(List.of(2L));
        when(qualities.selectListByCodesForUpdate(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").build()));
        assertEquals(1,service.submitGroup(req).get("processedCount"));
        var capture=org.mockito.ArgumentCaptor.forClass(MesQcSeedQualitySubmitReqVO.class);verify(service).submit(capture.capture());
        assertEquals("B2",capture.getValue().getCode());assertEquals(8L,capture.getValue().getQualifiedWarehouseId());
    }
    @Test void changedConfirmedListDoesNotSubmitAnyBag() {
        req.setBagIds(List.of(1L));assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(service,never()).submit(any());
    }
    @Test void unenteredBagBlocksWholeGroup() {
        second.setInboundEventId(null);assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(service,never()).submit(any());
    }
    @Test void wrongTypeAndCanceledBatchCannotSubmit() {
        req.setScanType("OTHER");assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));
        req.setScanType("BATCH");when(batches.selectByScanCodeForUpdate(anyString())).thenReturn(new MesProBatchDO().setId(10L).setCode("BATCH").setStatus(40));
        assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(service,never()).submit(any());
    }
    @Test void singleBagUsesSameConfirmedGroupProtocol() {
        req.setScanType("BAG");req.setCode("B1");req.setBagIds(List.of(1L));
        when(bags.selectByScanCode("B1")).thenReturn(first);when(bags.selectListByIdsForUpdate(any())).thenReturn(List.of(first));
        assertEquals(1,service.submitGroup(req).get("processedCount"));verify(service).submit(any());
    }
    @Test void failingSecondBagRollsBackWholeTransaction() {
        var source=new org.h2.jdbcx.JdbcDataSource();source.setURL("jdbc:h2:mem:qcgroup"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1");
        var jdbc=new org.springframework.jdbc.core.JdbcTemplate(source);jdbc.execute("CREATE TABLE qc_result(id BIGINT)");
        doAnswer(invocation->{var single=(MesQcSeedQualitySubmitReqVO)invocation.getArgument(0);jdbc.update("INSERT INTO qc_result(id) VALUES(?)",single.getCode().equals("B1")?1:2);if(single.getCode().equals("B2"))throw new IllegalStateException("库存流转失败");return 101L;}).when(service).submit(any());
        var factory=new org.springframework.aop.framework.ProxyFactory(service);
        factory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(new org.springframework.jdbc.datasource.DataSourceTransactionManager(source),new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
        var proxy=(MesQcSeedQualityService)factory.getProxy();
        assertThrows(IllegalStateException.class,()->proxy.submitGroup(req));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM qc_result",Integer.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityScanTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualitySubmitReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.MesQcSeedQualityMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SeedQualityScanTest {
    private final MesQcSeedQualityServiceImpl service = new MesQcSeedQualityServiceImpl();
    private final MesProBagCodeMapper bags = mock(MesProBagCodeMapper.class);
    private final MesQcSeedQualityMapper qualities = mock(MesQcSeedQualityMapper.class);
    @BeforeEach void setup() { ReflectionTestUtils.setField(service,"bagCodeMapper",bags); ReflectionTestUtils.setField(service,"qualityMapper",qualities); }
    private MesProBagCodeDO bag() { return new MesProBagCodeDO().setId(1L).setCode("TRACE").setBusinessCode("BUSINESS").setStatus(20).setInboundEventId(9L).setBatchCode("BATCH"); }
    @Test void businessScanReturnsCanonicalCodeAndPreviousResultWithoutWrites() {
        when(bags.selectByScanCode("BUSINESS")).thenReturn(bag());
        when(qualities.selectByCode("TRACE")).thenReturn(MesQcSeedQualityDO.builder().id(2L).result(1).build());
        var result = service.resolveScan("BUSINESS");
        assertEquals("TRACE",result.get("code"));assertEquals(2L,result.get("qualityId"));assertEquals(true,result.get("inbound"));
        verify(qualities,never()).insert(any(MesQcSeedQualityDO.class));
    }
    @Test void businessCodeRecordQueryUsesCanonicalCode() {
        when(bags.selectByScanCode("BUSINESS")).thenReturn(bag());
        var req = new MesQcSeedQualityPageReqVO();req.setCode("BUSINESS");service.getQualityPage(req);
        assertEquals("TRACE",req.getCode());verify(qualities).selectPage(req);
    }
    @Test void duplicateQualityIsRejectedAfterLockingSameBagForBothCodes() {
        when(bags.selectByScanCodeForUpdate("BUSINESS")).thenReturn(bag());
        var req = new MesQcSeedQualitySubmitReqVO();req.setCode("BUSINESS");
        when(qualities.selectByCodeForUpdate("TRACE")).thenReturn(MesQcSeedQualityDO.builder().id(2L).result(1).build());
        req.setIndicators(Collections.nCopies(4,new MesQcSeedQualitySubmitReqVO.Indicator()));
        assertThrows(IllegalArgumentException.class,()->service.submit(req));
        verify(qualities).selectByCodeForUpdate("TRACE");verify(qualities,never()).insert(any(MesQcSeedQualityDO.class));
    }
    @Test void unenteredAndCanceledBagsCannotSubmit() {
        var req = new MesQcSeedQualitySubmitReqVO();req.setCode("BUSINESS");req.setIndicators(Collections.nCopies(4,new MesQcSeedQualitySubmitReqVO.Indicator()));
        when(bags.selectByScanCodeForUpdate("BUSINESS")).thenReturn(bag().setInboundEventId(null));
        assertThrows(IllegalArgumentException.class,()->service.submit(req));
        when(bags.selectByScanCodeForUpdate("BUSINESS")).thenReturn(bag().setStatus(30));
        assertThrows(IllegalArgumentException.class,()->service.submit(req));verifyNoInteractions(qualities);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/BagBusinessOutboundTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.mes.service.wm.productsales;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class BagBusinessOutboundTest {
    @Test void businessCodeResolvesCanonicalBagForRepeatPick() {
        var f=new SalesPickingSelectionTest();f.setup();
        when(f.bags.selectByScanCodeForUpdate("BUS-31")).thenReturn(f.b1);
        when(f.details.selectPickingByBatchCodeForUpdate("PRO-100")).thenReturn(List.of(new MesWmProductSalesDetailDO().setId(77L).setSalesId(11L).setBagCodeId(31L)));
        f.req.setScanType("BAG");f.req.setScanContent("BUS-31");f.req.setBagIds(null);f.req.setQuantity(BigDecimal.ONE);
        var result=f.service.scanProductSales(f.req);assertEquals(31L,result.getCodeId());assertTrue(result.getAlreadyScanned());assertEquals(77L,result.getDetailId());f.noInsert();
    }
    @Test void palletPreviewCountsSelectableEffectiveBags() {
        var f=new SalesPickingSelectionTest();f.setup();f.b3.setStatus(30);
        var result=f.service.previewScan("SOURCE");assertEquals("PALLET",result.getScanType());assertEquals(BigDecimal.valueOf(2),result.getScanQuantity());assertEquals(2,result.getBags().size());f.noInsert();
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/SalesPickingSelectionTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,144 @@
package cn.iocoder.yudao.module.mes.service.wm.productsales;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.*;
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.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstockbag.MesWmMaterialStockBagDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.*;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstockbag.MesWmMaterialStockBagMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.*;
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;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import org.junit.jupiter.api.*;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SalesPickingSelectionTest {
    final MesWmProductSalesServiceImpl service = new MesWmProductSalesServiceImpl();
    final MesWmProductSalesMapper sales = mock(MesWmProductSalesMapper.class);
    final MesWmProductSalesDetailMapper details = mock(MesWmProductSalesDetailMapper.class);
    final MesProBagCodeMapper bags = mock(MesProBagCodeMapper.class);
    final MesProPalletMapper pallets = mock(MesProPalletMapper.class);
    final MesProBatchMapper batches = mock(MesProBatchMapper.class);
    final MesWmMaterialStockBagMapper inventory = mock(MesWmMaterialStockBagMapper.class);
    final MesProBagCodeService bagService = mock(MesProBagCodeService.class);
    final MesProPalletService palletService = mock(MesProPalletService.class);
    final MesProBatchService batchService = mock(MesProBatchService.class);
    final MesWmBatchService wmBatches = mock(MesWmBatchService.class);
    final MesWmMaterialStockService stocks = mock(MesWmMaterialStockService.class);
    final MesWmProductSalesLineService lines = mock(MesWmProductSalesLineService.class);
    final MesWmProductSalesDetailService detailService = mock(MesWmProductSalesDetailService.class);
    MesProBatchDO batch;
    MesProBagCodeDO b1, b2, b3;
    List<MesWmMaterialStockBagDO> stockBags;
    MesWmProductSalesScanReqVO req;
    @BeforeEach void setup() {
        Object[][] fields = {{"productSalesMapper",sales},{"productSalesDetailMapper",details},{"pickingBagMapper",bags},
            {"pickingPalletMapper",pallets},{"pickingBatchMapper",batches},{"pickingStockBagMapper",inventory},
            {"bagCodeService",bagService},{"palletService",palletService},{"proBatchService",batchService},
            {"mesWmBatchService",wmBatches},{"materialStockService",stocks},{"productSalesLineService",lines},{"productSalesDetailService",detailService}};
        for (var f : fields) ReflectionTestUtils.setField(service,(String)f[0],f[1]);
        batch = new MesProBatchDO().setId(100L).setCode("PRO-100").setItemId(1L).setStatus(30);
        b1=bag(31L); b2=bag(32L); b3=bag(33L);
        var pallet = new MesProPalletDO().setId(200L).setBatchId(100L).setStatus(10);
        when(sales.selectByIdForUpdate(11L)).thenReturn(new MesWmProductSalesDO().setId(11L).setStatus(2));
        when(sales.selectById(11L)).thenReturn(new MesWmProductSalesDO().setId(11L).setStatus(2));
        when(batchService.getBatch(100L)).thenReturn(batch);
        when(batchService.getBatchByScanCode("SOURCE")).thenReturn(batch);
        when(batches.selectByScanCodeForUpdate("SOURCE")).thenReturn(batch);
        when(pallets.selectByScanCodeForUpdate("SOURCE")).thenReturn(pallet);
        when(palletService.getPalletByScanCode("SOURCE")).thenReturn(pallet);
        when(bags.selectListByPalletIdForUpdate(200L)).thenReturn(List.of(b1,b2,b3));
        when(bags.selectListByBatchIdForUpdate(100L)).thenReturn(List.of(b1,b2,b3));
        when(bagService.getBagCodeListByPalletId(200L)).thenReturn(List.of(b1,b2,b3));
        when(bagService.getBagCodeListByBatchId(100L)).thenReturn(List.of(b1,b2,b3));
        when(wmBatches.getBatchByCode("PRO-100")).thenReturn(new MesWmBatchDO().setId(500L).setCode("PRO-100"));
        stockBags = List.of(stockBag(31L,900L),stockBag(32L,901L),stockBag(33L,900L));
        when(inventory.selectByBagIds(anyCollection(),anyBoolean())).thenAnswer(i -> stockBags.stream().filter(b -> ((Collection<?>)i.getArgument(0)).contains(b.getBagCodeId())).toList());
        when(stocks.getQualifiedMaterialStockListByItemAndBatch(1L,500L)).thenReturn(List.of(stock(900L,8L),stock(901L,9L)));
        when(lines.getProductSalesLineListBySalesId(11L)).thenReturn(List.of(new MesWmProductSalesLineDO().setId(21L).setItemId(1L).setQuantity(BigDecimal.TEN)));
        req = new MesWmProductSalesScanReqVO(); req.setSalesId(11L);req.setScanContent("SOURCE");req.setScanType("PALLET"); req.setBagIds(List.of(31L,32L));req.setQuantity(BigDecimal.valueOf(2));
    }
    MesProBagCodeDO bag(Long id) { return new MesProBagCodeDO().setId(id).setCode("TRACE-"+id).setBusinessCode("BUS-"+id).setBatchId(100L).setPalletId(200L).setStatus(20).setQcStatus(20); }
    MesWmMaterialStockBagDO stockBag(Long bagId,Long stockId) { return MesWmMaterialStockBagDO.builder().bagCodeId(bagId).materialStockId(stockId).itemId(1L).batchId(500L).status(1).stockState(20).frozen(false).quantity(BigDecimal.ONE).build(); }
    MesWmMaterialStockDO stock(Long id,Long warehouse) { return new MesWmMaterialStockDO().setId(id).setWarehouseId(warehouse).setQuantity(BigDecimal.TEN); }
    void noInsert() { verify(details,never()).insert(any(MesWmProductSalesDetailDO.class)); }
    @Test void partialPalletWritesExactlySelectedBagsAtTheirActualLocations() {
        var result=service.scanProductSales(req);
        var c=ArgumentCaptor.forClass(MesWmProductSalesDetailDO.class);verify(details,times(2)).insert(c.capture());
        assertEquals(List.of(31L,32L),c.getAllValues().stream().map(MesWmProductSalesDetailDO::getBagCodeId).toList());
        assertEquals(List.of(8L,9L),c.getAllValues().stream().map(MesWmProductSalesDetailDO::getWarehouseId).toList());
        assertTrue(c.getAllValues().stream().allMatch(d -> BigDecimal.ONE.equals(d.getQuantity()) && d.getRemark().contains("TRACE-")));
        assertEquals(BigDecimal.valueOf(2),result.getOutboundQuantity());
    }
    @Test void partialBatchAlsoWritesPerBag() { req.setScanType("BATCH");var r=service.scanProductSales(req); assertEquals("BATCH",r.getScanType());verify(details,times(2)).insert(any(MesWmProductSalesDetailDO.class)); }
    @Test void scopeAndQuantityForgeryAreRejected() {
        req.setBagIds(List.of(31L,999L));assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));
        req.setBagIds(List.of(31L,32L));req.setQuantity(BigDecimal.ONE);assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));noInsert();
    }
    @Test void duplicateOrMissingSelectionIsRejected() {
        req.setBagIds(List.of(31L,31L));assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));
        req.setBagIds(null);assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));noInsert();
    }
    @Test void unqualifiedUnselectedBagDoesNotBlockPartialPick() { b3.setQcStatus(10);assertFalse(service.scanProductSales(req).getAlreadyScanned()); }
    @Test void selectedUnqualifiedFrozenOrOutboundBagBlocksEntireRequest() {
        b2.setQcStatus(10);assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));b2.setQcStatus(20);
        stockBags.get(1).setFrozen(true);assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));stockBags.get(1).setFrozen(false);
        stockBags.get(1).setStatus(2);assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));noInsert();
    }
    @Test void crossOrderAndLegacyWholePalletOverlapAreRejected() {
        when(details.selectPickingByBatchCodeForUpdate("PRO-100")).thenReturn(List.of(new MesWmProductSalesDetailDO().setSalesId(12L).setBagCodeId(31L)));
        assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));
        when(details.selectPickingByBatchCodeForUpdate("PRO-100")).thenReturn(List.of(new MesWmProductSalesDetailDO().setSalesId(11L).setPalletCodeId(200L)));
        assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));noInsert();
    }
    @Test void sameExactBagsAreIdempotentButPartialRepeatIsRejected() {
        var d1=new MesWmProductSalesDetailDO().setSalesId(11L).setBagCodeId(31L);var d2=new MesWmProductSalesDetailDO().setSalesId(11L).setBagCodeId(32L);
        when(details.selectPickingByBatchCodeForUpdate("PRO-100")).thenReturn(List.of(d1,d2));assertTrue(service.scanProductSales(req).getAlreadyScanned());
        when(details.selectPickingByBatchCodeForUpdate("PRO-100")).thenReturn(List.of(d1));assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));noInsert();
    }
    @Test void previewReportsAvailabilityWithoutWrites() {
        b3.setQcStatus(10);stockBags.get(1).setFrozen(true);
        var r=service.previewScan("SOURCE","BATCH",11L);assertEquals(BigDecimal.ONE,r.getScanQuantity());assertEquals(3,r.getBagCount());
        assertTrue(r.getBags().get(0).getSelectable());assertEquals("袋码已冻结",r.getBags().get(1).getUnavailableReason());noInsert();verify(wmBatches,never()).getOrCreateByProBatch(any());
    }
    @Test void canceledBatchInvalidTypeAndPlanOverflowRejectWrites() {
        batch.setStatus(40);assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));batch.setStatus(30);
        req.setScanType("OTHER");assertThrows(IllegalArgumentException.class,()->service.scanProductSales(req));req.setScanType("BATCH");
        when(lines.getProductSalesLineListBySalesId(11L)).thenReturn(List.of(new MesWmProductSalesLineDO().setId(21L).setItemId(1L).setQuantity(BigDecimal.ONE)));
        assertThrows(RuntimeException.class,()->service.scanProductSales(req));noInsert();
    }
    @Test void aggregateStockInsufficientRejectsAllRows() {
        when(stocks.getQualifiedMaterialStockListByItemAndBatch(1L,500L)).thenReturn(List.of(stock(900L,8L).setQuantity(BigDecimal.ZERO),stock(901L,9L)));
        assertThrows(RuntimeException.class,()->service.scanProductSales(req));noInsert();
    }
    @Test void detailResponseKeepsBagAndPalletAssociationsForRecovery() {
        var source=new MesWmProductSalesDetailDO().setBagCodeId(31L).setPalletCodeId(200L).setQuantity(BigDecimal.ONE);
        var response=cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean(source,cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.detail.MesWmProductSalesDetailRespVO.class);
        assertEquals(31L,response.getBagCodeId());assertEquals(200L,response.getPalletCodeId());
    }
    @Test void secondInsertFailureRollsBackAllSelectedBagDetails() {
        var source=new org.h2.jdbcx.JdbcDataSource();source.setURL("jdbc:h2:mem:pick"+UUID.randomUUID()+";DB_CLOSE_DELAY=-1");
        var jdbc=new org.springframework.jdbc.core.JdbcTemplate(source);jdbc.execute("CREATE TABLE picking(bag_id BIGINT)");
        doAnswer(i->{MesWmProductSalesDetailDO d=i.getArgument(0);jdbc.update("INSERT INTO picking VALUES (?)",d.getBagCodeId());if(d.getBagCodeId()==32L)throw new IllegalStateException("第二袋写入失败");return 1;}).when(details).insert(any(MesWmProductSalesDetailDO.class));
        var factory=new org.springframework.aop.framework.ProxyFactory(service);
        factory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(new org.springframework.jdbc.datasource.DataSourceTransactionManager(source),new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
        var proxy=(MesWmProductSalesService)factory.getProxy();
        assertThrows(IllegalStateException.class,()->proxy.scanProductSales(req));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM picking",Integer.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/SalesPickingSqlTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.mes.service.wm.productsales;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper;
import org.apache.ibatis.annotations.Select;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** æ‰§è¡Œç”Ÿäº§ Mapper ä¸­çš„实际占用查询 SQL。 */
class SalesPickingSqlTest {
    JdbcTemplate db;NamedParameterJdbcTemplate named;
    @BeforeEach void setup(){
        var source=new JdbcDataSource();source.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
        db=new JdbcTemplate(source);named=new NamedParameterJdbcTemplate(source);
        db.execute("CREATE TABLE mes_wm_product_sales(id BIGINT PRIMARY KEY,status INT,deleted INT DEFAULT 0)");
        db.execute("CREATE TABLE mes_wm_product_sales_detail(id BIGINT PRIMARY KEY,sales_id BIGINT,bag_code_id BIGINT,pallet_code_id BIGINT,batch_code VARCHAR(64),deleted INT DEFAULT 0)");
        db.update("INSERT INTO mes_wm_product_sales(id,status) VALUES(1,2),(2,10),(3,3),(4,4),(5,5)");
        db.update("INSERT INTO mes_wm_product_sales_detail(id,sales_id,bag_code_id,pallet_code_id,batch_code) VALUES(1,1,31,200,'B1'),(2,2,32,200,'B1'),(3,3,NULL,201,'B1'),(4,4,34,202,'B1'),(5,5,35,203,'B1'),(6,1,36,204,'B2'),(7,1,37,205,'B1')");
        db.update("UPDATE mes_wm_product_sales_detail SET deleted=1 WHERE id=7");
    }
    List<Long> run(String method)throws Exception {
        String sql=String.join(" ",MesWmProductSalesDetailMapper.class.getMethod(method,String.class).getAnnotation(Select.class).value()).replace("#{batchCode}",":batchCode");
        return named.query(sql,Map.of("batchCode","B1"),(rs,n)->rs.getLong("id"));
    }
    @Test void onlyLivePickingOrdersAndLegacyPalletsOccupyBags()throws Exception{assertEquals(List.of(1L,2L,3L),run("selectPickingByBatchCode"));}
    @Test void lockedCurrentReadHasSameScopeAndCanceledOrderReleasesBags()throws Exception{
        assertEquals(List.of(1L,2L,3L),run("selectPickingByBatchCodeForUpdate"));db.update("UPDATE mes_wm_product_sales SET status=5 WHERE id=1");
        assertEquals(List.of(2L,3L),run("selectPickingByBatchCodeForUpdate"));
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java
@@ -93,7 +93,8 @@
    @GetMapping("/get-permission-info")
    @Operation(summary = "获取登录用户的权限信息")
    @DataPermission(enable = false) // å¿½ç•¥æ•°æ®æƒé™ï¼Œé¿å…å› ä¸ºè¿‡æ»¤ï¼Œå¯¼è‡´æ— æ³•查询用户。类似:https://t.zsxq.com/LHnrp
    public CommonResult<AuthPermissionInfoRespVO> getPermissionInfo() {
    public CommonResult<AuthPermissionInfoRespVO> getPermissionInfo(
            @RequestParam(defaultValue = "pc") @jakarta.validation.constraints.Pattern(regexp = "pc|mobile") String terminalType) {
        // 1.1 èŽ·å¾—ç”¨æˆ·ä¿¡æ¯
        AdminUserDO user = userService.getUser(getLoginUserId());
        if (user == null) {
@@ -114,7 +115,11 @@
        menuList = menuService.filterDisableMenus(menuList);
        // 2. æ‹¼æŽ¥ç»“果返回
        return success(AuthConvert.INSTANCE.convert(user, roles, menuList));
        AuthPermissionInfoRespVO result = AuthConvert.INSTANCE.convert(user, roles, menuList);
        if ("mobile".equals(terminalType)) {
            result.setMenus(MobileMenuAdapter.adapt(result.getMenus()));
        }
        return success(result);
    }
    @PostMapping("/register")
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.system.controller.admin.auth;
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthPermissionInfoRespVO.MenuVO;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** 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");
    public static List<MenuVO> adapt(List<MenuVO> menus) {
        List<MenuVO> result = new ArrayList<>();
        collect(menus, result);
        return result;
    }
    private static void collect(List<MenuVO> menus, List<MenuVO> result) {
        if (menus == null) return;
        for (MenuVO menu : menus) {
            if (!Boolean.TRUE.equals(menu.getVisible())) continue;
            String component = menu.getComponent();
            String key = component == null ? null : PAGES.get(component.replaceFirst("^/", ""));
            if (key != null) {
                result.add(MenuVO.builder().id(menu.getId()).parentId(0L).name(menu.getName())
                        .path("/mobile/" + key).component("mobile/" + key).visible(true)
                        .keepAlive(false).alwaysShow(false).build());
            }
            collect(menu.getChildren(), result);
        }
    }
}
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/controller/admin/auth/MobileMenuAdapterTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.system.controller.admin.auth;
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthPermissionInfoRespVO.MenuVO;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MobileMenuAdapterTest {
  @Test void qualityAndOutboundRetainOnlyAlreadyAuthorizedVisibleMenus() {
    var root=menu(1L,null,true);
    root.setChildren(List.of(menu(2L,"mes/qc/seedQuality/index",true),menu(3L,"mes/qc/seedQuality/record",true),menu(4L,"wls/productsales/index",true),menu(5L,"mes/qc/iqc/index",true)));
    assertEquals(List.of("/mobile/quality","/mobile/quality-records","/mobile/sales"),MobileMenuAdapter.adapt(List.of(root)).stream().map(MenuVO::getPath).toList());
    root.setVisible(false);assertTrue(MobileMenuAdapter.adapt(List.of(root)).isEmpty());
  }
  @Test void mapsOnlyVisibleAuthorizedSupportedMenusWithoutChangingPcTree() {
    MenuVO bag=menu(2L,"/mes/pro/bagCode/index",true);
    MenuVO stock=menu(3L,"wls/materialstock/index",true);
    MenuVO hidden=menu(4L,null,false);hidden.setChildren(List.of(stock));
    MenuVO unsupported=menu(5L,"system/user/index",true);
    MenuVO root=menu(1L,null,true);root.setChildren(List.of(bag,hidden,unsupported));
    List<MenuVO> result=MobileMenuAdapter.adapt(List.of(root));
    assertEquals(1,result.size());assertEquals(2L,result.get(0).getId());
    assertEquals("/mobile/bags",result.get(0).getPath());
    assertEquals("/mes/pro/bagCode/index",bag.getComponent());
    assertEquals(3,root.getChildren().size());
    assertTrue(MobileMenuAdapter.adapt(null).isEmpty());
  }
  private MenuVO menu(Long id,String component,boolean visible) {
    return MenuVO.builder().id(id).name("menu").component(component).visible(visible).build();
  }
}