云
2 天以前 87733eb3623593da247c051593d64b8ff9e1d3a4
feat(crm,mes): 扩展客户字段与重构批次号生成逻辑

- 客户实体新增社会信用代码及上级经销商等扩展字段
- 客户查询与保存接口支持新增字段的传入与返回
- 增加客户所属上级经销商不能选择自身的校验逻辑
- 重构生产批次号生成规则为四位年份加三位流水号
- 生产批次支持手动输入批次号或系统自动生成
- 简化工作站仓库层级处理并移除库区库位强校验
- 批次列表接口新增返回关联的正式托盘码列表
- 新增袋码与托盘码的批量扫码内容反查方法
- 更新扫码入库与库存袋码查询相关的接口文档
已添加38个文件
已删除2个文件
已修改90个文件
5238 ■■■■■ 文件已修改
docs/crm_customer_dealer_fields_integration.md 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/material_stock_transaction_spec_category.md 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/mes_scan_event_integration.md 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/mes_wm_material_stock_bag_api.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/pro_batch_code_rule_integration.md 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/pro_batch_pallet_code_column_integration.md 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_bpm_migration_plan.md 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_seed_indicator_range_product_integration.md 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_seed_quality_batch_fold_integration.md 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/qc_seed_quality_dual_line_integration.md 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/scan_event_pallet_code_integration.md 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/scan_in_multi_type_inbound.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/system_appearance_integration.md 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/warehouse_location_area_removal.md 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/销售出库分次发货说明.md 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/deploy-seed-quality-bpm.ps1 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
seed-range-redesign.png 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/crm_customer_dealer_fields_20261106.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_qc_approver_menu_retire_20261106.sql 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_qc_seed_indicator_range_product_20261105.sql 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_seed_quality_dual_line_20261105.sql 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_wm_product_sales_shipment.sql 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_wm_remove_warehouse_location_area_menu.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/qc_seed_quality_line_menus_20261106.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/system_appearance_init_20261106.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerPageReqVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerRespVO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerSaveReqVO.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/customer/CrmCustomerDO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/customer/CrmCustomerMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/MesProBatchController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | 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/scan/vo/MesProScanEventCreateReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/vo/MesProScanEventRespVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/MesQcSeedQualityController.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedIndicatorRangeRespVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedIndicatorRangeSaveReqVO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityBatchRespVO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityPageReqVO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityRespVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityScopeRetestReqVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualitySubmitReqVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedReturnQualitySubmitReqVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/batch/vo/MesWmBatchSalesTargetRespVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/MesWmMaterialStockController.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockAdjustReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockBagPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockBagRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/miscissue/vo/line/MesWmMiscIssueLineSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/miscreceipt/vo/line/MesWmMiscReceiptLineSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesDetailController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesLineController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesRespVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanRespVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/detail/MesWmProductSalesDetailRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/line/MesWmProductSalesLineRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnissue/vo/detail/MesWmReturnIssueDetailSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/detail/MesWmReturnSalesDetailSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/stocktaking/task/vo/result/MesWmStockTakingTaskResultSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transaction/MesWmTransactionController.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transaction/vo/MesWmTransactionPageReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transaction/vo/MesWmTransactionRespVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/warehouse/MesWmWarehouseAreaController.java 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/warehouse/MesWmWarehouseLocationController.java 127 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/batch/MesProBatchDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/scan/MesProScanEventDO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/seedquality/MesQcSeedIndicatorRangeDO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/seedquality/MesQcSeedQualityDO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/seedquality/MesQcSeedQualityIndicatorDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesDO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesDetailDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesShipmentDO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/transaction/MesWmTransactionDO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/batch/MesProBatchMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/pallet/MesProPalletMapper.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedIndicatorRangeMapper.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityMapper.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstock/MesWmMaterialStockMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | 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 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesShipmentMapper.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/transaction/MesWmTransactionMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/MesCodeRedisDAO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/wm/MesWmProductSalesStatusEnum.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationServiceImpl.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/batch/MesProBatchServiceImpl.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/scan/MesProScanEventServiceImpl.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedIndicatorRangeService.java 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityService.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java 910 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/listener/MesQcSeedCompanyStatusListener.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/listener/MesQcSeedLineStatusListener.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/listener/MesQcSeedReturnStatusListener.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/trace/MesProTracePublicServiceImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/materialstock/MesWmMaterialStockServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesDetailServiceImpl.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/transaction/MesWmTransactionServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/transaction/dto/MesWmTransactionSaveReqDTO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/warehouse/MesWmWarehouseAreaServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/warehouse/MesWmWarehouseLocationServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/warehouse/MesWmWarehouseServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/resources/bpm/mes_seed_quality_company_approve.bpmn20.xml 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/resources/bpm/mes_seed_quality_line_approve.bpmn20.xml 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/resources/bpm/mes_seed_return_approve.bpmn20.xml 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/resources/mapper/wm/batch/MesWmBatchMapper.xml 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedIndicatorRangeServiceTest.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityGroupTest.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityScanTest.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityWorkflowTest.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/appearance/SystemAppearanceController.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/appearance/vo/SystemAppearanceRespVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/appearance/vo/SystemAppearanceSaveReqVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageApplicationTypeEnum.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/appearance/SystemAppearanceService.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/appearance/SystemAppearanceServiceImpl.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_customer_dealer_fields_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
# å®¢æˆ·ç®¡ç†æ–°å¢žå­—段(社会信用代码等 7 é¡¹ï¼‰- å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- CRM â†’ å®¢æˆ·ç®¡ç† â†’ å®¢æˆ·åˆ—表
- CRM â†’ å®¢æˆ·ç®¡ç† â†’ æ–°å¢ž/编辑客户弹窗
- CRM â†’ å®¢æˆ·ç®¡ç† â†’ å®¢æˆ·è¯¦æƒ…
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. æ–°å¢ž/编辑客户时,可录入 7 ä¸ªæ–°å­—段,保存后持久化到 `crm_customer` è¡¨ã€‚
2. åˆ—表展示 7 ä¸ªæ–°å­—段;「所属上级经销商」展示其名称(`parentDealerName`)。
3. åˆ—表支持按 6 ä¸ªæ–°å­—段筛选(社会信用代码、身份证号、客户姓名、备案流水号、所属区域、所属上级经销商)。
4. ã€Œæ‰€å±žä¸Šçº§ç»é”€å•†ã€å¤ç”¨ CRM å®¢æˆ·ä¸»æ•°æ®ï¼Œé€‰æ‹©å¼¹çª—与客户选择组件一致;后端校验不能选择客户自身。
5. è¯¦æƒ…页展示 7 ä¸ªæ–°å­—段,其中「所属上级经销商」展示名称。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /crm/customer/page | å®¢æˆ·åˆ†é¡µï¼ˆæ–°å¢žç­›é€‰å‚数、响应字段) |
| POST | /crm/customer/create | æ–°å¢žå®¢æˆ·ï¼ˆæ–°å¢žè¯·æ±‚字段) |
| PUT | /crm/customer/update | ä¿®æ”¹å®¢æˆ·ï¼ˆæ–°å¢žè¯·æ±‚字段) |
| GET | /crm/customer/get | å®¢æˆ·è¯¦æƒ…(新增响应字段) |
### è¯·æ±‚字段新增(create / update)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| socialCreditCode | String | å¦ | ç¤¾ä¼šä¿¡ç”¨ä»£ç ï¼Œæœ€é•¿ 64 å­—符 |
| idCardNo | String | å¦ | èº«ä»½è¯å·ï¼Œæœ€é•¿ 32 å­—符 |
| customerName | String | å¦ | å®¢æˆ·å§“名,最长 64 å­—符 |
| filingSerialNo | String | å¦ | å¤‡æ¡ˆæµæ°´å·ï¼Œæœ€é•¿ 64 å­—符 |
| bankAccountName | String | å¦ | é“¶è¡Œè´¦æˆ·åç§°ï¼Œæœ€é•¿ 128 å­—符 |
| region | String | å¦ | æ‰€å±žåŒºåŸŸï¼Œæœ€é•¿ 64 å­—符 |
| parentDealerId | Long | å¦ | æ‰€å±žä¸Šçº§ç»é”€å•†ç¼–号,关联客户表 `id` |
### æŸ¥è¯¢å‚数新增(page)
| å‚æ•° | ç±»åž‹ | åŒ¹é…æ–¹å¼ | è¯´æ˜Ž |
|------|------|----------|------|
| socialCreditCode | String | æ¨¡ç³ŠåŒ¹é… | ç¤¾ä¼šä¿¡ç”¨ä»£ç  |
| idCardNo | String | æ¨¡ç³ŠåŒ¹é… | èº«ä»½è¯å· |
| customerName | String | æ¨¡ç³ŠåŒ¹é… | å®¢æˆ·å§“名 |
| filingSerialNo | String | æ¨¡ç³ŠåŒ¹é… | å¤‡æ¡ˆæµæ°´å· |
| region | String | æ¨¡ç³ŠåŒ¹é… | æ‰€å±žåŒºåŸŸ |
| parentDealerId | Long | ç²¾ç¡®åŒ¹é… | æ‰€å±žä¸Šçº§ç»é”€å•†ç¼–号 |
### å“åº”字段新增(page / get)
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| socialCreditCode | String | ç¤¾ä¼šä¿¡ç”¨ä»£ç  |
| idCardNo | String | èº«ä»½è¯å· |
| customerName | String | å®¢æˆ·å§“名 |
| filingSerialNo | String | å¤‡æ¡ˆæµæ°´å· |
| bankAccountName | String | é“¶è¡Œè´¦æˆ·åç§° |
| region | String | æ‰€å±žåŒºåŸŸ |
| parentDealerId | Long | æ‰€å±žä¸Šçº§ç»é”€å•†ç¼–号 |
| parentDealerName | String | æ‰€å±žä¸Šçº§ç»é”€å•†åç§°ï¼ˆç”±åŽç«¯æ‰¹é‡å›žå¡«ï¼Œä»…展示用) |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "id": 1001,
    "name": "某某农业合作社",
    "customerName": "张三",
    "socialCreditCode": "91320600MA1XXXXXXX",
    "idCardNo": "320602199001011234",
    "filingSerialNo": "BA2026000001",
    "bankAccountName": "南通某某农业有限公司",
    "region": "南通",
    "parentDealerId": 900,
    "parentDealerName": "某某经销商"
  },
  "msg": ""
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| customerName | æ–°å¢ž/编辑、列表、筛选、详情 | å®¢æˆ·å§“名,与「客户名称」并存 |
| socialCreditCode | æ–°å¢ž/编辑、列表、筛选、详情 | ç¤¾ä¼šä¿¡ç”¨ä»£ç  |
| idCardNo | æ–°å¢ž/编辑、列表、筛选、详情 | èº«ä»½è¯å· |
| filingSerialNo | æ–°å¢ž/编辑、列表、筛选、详情 | å¤‡æ¡ˆæµæ°´å· |
| bankAccountName | æ–°å¢ž/编辑、列表、详情 | é“¶è¡Œè´¦æˆ·åç§° |
| region | æ–°å¢ž/编辑、列表、筛选、详情 | æ‰€å±žåŒºåŸŸï¼ˆè‡ªç”±æ–‡æœ¬ï¼‰ |
| parentDealerId | æ–°å¢ž/编辑、筛选 | æ‰€å±žä¸Šçº§ç»é”€å•†ï¼Œå¤ç”¨å®¢æˆ·é€‰æ‹©ç»„ä»¶ |
| parentDealerName | åˆ—表、详情 | æ‰€å±žä¸Šçº§ç»é”€å•†åç§°ï¼Œç©ºå€¼å±•示 `-` |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ‰€å±žä¸Šçº§ç»é”€å•† | å¤ç”¨ CRM å®¢æˆ·ä¸»æ•°æ®ï¼Œè‡ªå…³è” `parent_dealer_id â†’ crm_customer.id` |
| è‡ªå…³è”校验 | ç¼–辑客户时,所属上级经销商不能选择客户自身,后端返回「所属上级经销商不能选择客户自身」 |
| æ•°æ®æƒé™ | åˆ—表查询仍受 CRM æ•°æ®æƒé™æŽ§åˆ¶ï¼Œã€Œæ‰€å±žä¸Šçº§ç»é”€å•†ã€ä¸‹æ‹‰å¯é€‰èŒƒå›´ç”±å®¢æˆ·é€‰æ‹©ç»„件决定 |
| ç©ºå€¼ | æ–°å¢žå­—段均非必填;列表空值展示 `-` |
## æ³¨æ„äº‹é¡¹
- ã€Œå®¢æˆ·å§“名」(`customerName`)与既有「客户名称」(`name`)为两个独立字段,不要混用。
- ã€Œæ‰€å±žåŒºåŸŸã€ï¼ˆ`region`)为自由文本,与既有「地址」(`areaId` çœå¸‚区级联)互不影响。
- æ–°å¢žå­—段已加入导出 Excel åˆ—,导出文件自动包含;`parentDealerName` ä»…用于展示,不可作为保存入参。
- æ•°æ®åº“需先执行 `sql/mysql/crm_customer_dealer_fields_20261106.sql`。
docs/material_stock_transaction_spec_category.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
# åº“存现有量/库存事务流水 è§„格与类别字段 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- åº“存现有量(`src/views/wls/materialstock`)
- åº“存现有量选择弹窗(物料库存选择,`useSelectGridColumns`)
- åº“存事务流水(`src/views/wls/transaction`)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. åº“存现有量列表:物料主数据的 `specification`(规格型号)展示为“规格”列;物料所属 MDM åˆ†ç±»åç§°å±•示为“类别”列。
2. åº“存事务流水列表:在原有物料编码、物料名称之后,补充“规格”和“类别”两列,数据同样来自物料主数据。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /mes/wm/material-stock/page | åº“存现有量分页 |
| GET | /mes/wm/material-stock/get | åº“存现有量详情 |
| GET | /mes/wm/material-stock/picking-list | é”€å”®å‡ºåº“按行查询合格库存 |
| GET | /mes/wm/transaction/page | åº“存事务流水分页 |
### å“åº”字段新增
**`MesWmMaterialStockRespVO`:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| itemTypeName | String | ç‰©æ–™ç±»åˆ«åç§°ï¼ˆMDM ç‰©æ–™åˆ†ç±»åç§°ï¼‰ |
原字段 `specification`(规格型号)继续保留,由前端用于“规格”列展示。
**`MesWmTransactionRespVO`:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| specification | String | è§„格型号(品种未维护时回退 MES ç‰©æ–™è§„格型号) |
| itemTypeName | String | ç‰©æ–™ç±»åˆ«åç§°ï¼ˆä¼˜å…ˆ MDM ç‰©æ–™åˆ†ç±»ï¼Œå…¶æ¬¡ MES ç‰©æ–™åˆ†ç±»ï¼‰ |
| palletCode | String | æ‰˜ç›˜ç ï¼ˆé”€å”®å‡ºåº“取销售明细托盘,其余流水回退取袋码当前绑定托盘) |
| shipmentId | Number | å‘货单编号 |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "itemCode": "M001",
    "itemName": "钢板",
    "specification": "10mm*100mm",
    "itemTypeName": "五金配件"
  }
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| itemTypeName | åº“存现有量列表、选择弹窗、事务流水列表 | åˆ—名“类别” |
| specification | åº“存现有量列表、选择弹窗、事务流水列表 | åˆ—名“规格” |
| palletCode | äº‹åŠ¡æµæ°´åˆ—è¡¨ | åˆ—名“托盘码” |
**列顺序:**
- åº“存事务流水:事务类型 â†’ ä¸šåŠ¡ç±»åž‹ â†’ ç‰©æ–™ç¼–码 â†’ ç‰©æ–™åç§° â†’ è§„æ ¼ â†’ ç±»åˆ« â†’ ä¸šåŠ¡å•å· â†’ æ‰¹æ¬¡å· â†’ æ‰˜ç›˜ç  â†’ è¢‹ç  â†’ æ•°é‡ â†’ ä»“库 â†’ äº‹åŠ¡æ—¶é—´ã€‚
- åº“存现有量:产品物料编码 â†’ äº§å“ç‰©æ–™åç§° â†’ è§„æ ¼ â†’ ç±»åˆ« â†’ åº“存状态 â†’ æ•°é‡åˆ—…
- åº“存现有量选择弹窗:产品物料编码 â†’ äº§å“ç‰©æ–™åç§° â†’ è§„æ ¼ â†’ ç±»åˆ« â†’ å…¥åº“批次号…
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| ç±»åˆ«åç§°æ¥æº | ä¼˜å…ˆæŒ‰ç‰©æ–™ä¸»æ•°æ®çš„ `categoryId` æŸ¥è¯¢ MDM ç‰©æ–™åˆ†ç±»ï¼›æœªç»´æŠ¤åˆ†ç±»æ—¶å›žé€€æŸ¥ MES ç‰©æ–™ï¼ˆ`mes_md_item.mdm_item_id`)的物料分类(`mes_md_item_type.name`) |
| è§„格来源 | ä¼˜å…ˆå–物料主数据的 `specification`;为空时回退取 MES ç‰©æ–™çš„ `specification` |
| æ‰˜ç›˜ç æ¥æº | é”€å”®å‡ºåº“流水按“销售单+袋码+发货单”匹配销售明细中的托盘;其他流水(质检、生产入库等)取袋码当前绑定的托盘码 |
| ç‰©æ–™ç¼ºå¤± | ç‰©æ–™ä¿¡æ¯ä¸å­˜åœ¨æ—¶ï¼Œè§„格与类别返回空 |
| åˆ†ç±»ç¼ºå¤± | ç‰©æ–™æœªç»´æŠ¤åˆ†ç±»æˆ–分类已删除时,类别返回空 |
| æ•°æ®ç»´æŠ¤å…¥å£ | è§„格型号在“品种管理”或“MES ç‰©æ–™â€ç»´æŠ¤ï¼›MDM åˆ†ç±»éœ€åœ¨ `/mdm/category` ç»´æŠ¤ï¼ŒMES ç‰©æ–™åˆ†ç±»åœ¨â€œMES ç‰©æ–™åˆ†ç±»â€ç»´æŠ¤ |
## æ³¨æ„äº‹é¡¹
- åº“存现有量页面原有的“单位”列已替换为“规格”,“规格型号”列已替换为“类别”,不再展示计量单位。
- ç‰©æ–™åç§°ã€è§„格、类别三列需相邻展示,并整体前移;库存事务流水前移到业务单号之前,库存现有量前移到库存状态之前。
- Excel å¯¼å‡ºä¸­æ–°å¢žâ€œç±»åˆ«â€åˆ—,原有“规格型号”“计量单位”列保持不变。
docs/mes_scan_event_integration.md
@@ -29,10 +29,10 @@
| eventId | String | æ˜¯ | è¾¹ç¼˜è®¾å¤‡ç”Ÿæˆçš„幂等事件号 |
| bagCode | String | æ˜¯ | å·¥ä¸šç›¸æœºè¯†åˆ«å‡ºçš„袋码 |
| quantity | Decimal | æ˜¯ | å…¥åº“数量,本期一袋通常为 1 |
| deviceCode | String | æ˜¯ | è®¾å¤‡ç¼–码,用于审计 |
| deviceCode | String | å¦ | è®¾å¤‡ç¼–码,用于审计 |
| lineCode | String | å¦ | äº§çº¿ç¼–码,用于审计 |
| locationId | Long | æ˜¯ | WIP æš‚存库区编号 |
| areaId | Long | æ˜¯ | WIP æš‚存库位编号 |
| locationId | Long | å¦ | WIP æš‚存库区编号;仓库已改为「仓库 â†’ åº“存」两级结构,可不传 |
| areaId | Long | å¦ | WIP æš‚存库位编号;可不传 |
| remark | String | å¦ | å¤‡æ³¨ |
**响应字段:**
@@ -100,4 +100,4 @@
- è¾¹ç¼˜ç¨‹åºä¸å¾—上传图片或视频,本接口只接收解码后的二维码结果。
- å‰ç«¯å·¥ä½œå°åº”在页面卸载时停止刷新任务。
- å…¥åº“失败时展示后端返回的业务错误,不自动调用通用库存调整接口重试。
- WIP åº“区和库位需提前配置;接口不接受客户端指定仓库编号。
- æ‰«ç å…¥åº“统一落到 WIP ç”Ÿäº§æš‚存仓;库区、库位可不传,接口不接受客户端指定仓库编号。
docs/mes_wm_material_stock_bag_api.md
@@ -40,6 +40,7 @@
|------|------|------|------|
| materialStockId | Long | æ˜¯ | åº“存汇总记录 ID |
| bagCode | String | å¦ | è¢‹ç æ¨¡ç³ŠæŸ¥è¯¢ |
| includeHistory | Boolean | å¦ | ä¸º true æ—¶åŒæ—¶è¿”回已出库、已报废袋码;默认仅返回在库袋码 |
| pageNo | Integer | æ˜¯ | é¡µç  |
| pageSize | Integer | æ˜¯ | æ¯é¡µæ¡æ•° |
@@ -64,6 +65,6 @@
- åŒä¸€è¢‹ç åªèƒ½å­˜åœ¨ä¸€æ¡åº“存袋码明细。
- è´¨æ£€æŒ‰å®žé™…袋码定位暂存库存,不按批次随机扣减。
- æŠ¥åºŸè¢‹ç ä¸è®¡å…¥åœ¨åº“明细查询,但保留明细记录用于追溯。
- æŠ¥åºŸå’Œå·²å‡ºåº“袋码不计入默认在库明细查询,可通过 `includeHistory` æŸ¥è¯¢ä¿ç•™çš„当前状态。已出库不一定是销售发货,应结合出库流水判断业务类型。
- æ— æ³•从历史汇总库存还原袋码时,不伪造袋码明细。
- SQL è„šæœ¬ä¸åŒ…å« `tenant_id` å­—段。
docs/pro_batch_code_rule_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
# ç”Ÿäº§æ‰¹æ¬¡å·è§„则 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- æ‰¹æ¬¡ç”Ÿäº§ç®¡ç† - æ–°å¢žæ‰¹æ¬¡ï¼ˆ`/mes/pro/batch` æ–°å¢žå¼¹çª—)
- æ‰¹æ¬¡ç”Ÿäº§ç®¡ç† - è¡¥å½•批次信息(临时批次码补录弹窗)
- æ‰¹æ¬¡ç”Ÿäº§ç®¡ç† - ç¼–辑批次
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. æ–°å¢žæ‰¹æ¬¡ï¼šæ‰¹æ¬¡å·å­—段可由人工填写;留空时由后端按「当前年份 + 3 ä½æµæ°´ã€è‡ªåŠ¨ç”Ÿæˆï¼ˆå¦‚ 2026001)。
2. é¢„生成二维码 -> è¡¥å½•批次信息:补录时同样支持人工填写批次号,留空则自动生成;生成后临时批次码仍保留用于扫码追溯。
3. ç¼–辑批次:已生成正式批次号的批次,批次号不再变化(修改加工单位不会重新生成批次号);仅历史临时批次(尚无正式批次号)在编辑时补生成。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /mes/pro/batch/create | åˆ›å»ºç”Ÿäº§æ‰¹æ¬¡ |
| PUT | /mes/pro/batch/fill-info | è¡¥å½•生产批次业务信息 |
| PUT | /mes/pro/batch/update | æ›´æ–°ç”Ÿäº§æ‰¹æ¬¡ |
**请求参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| code | String | å¦ | æ‰¹æ¬¡å·ã€‚不传或留空时由系统按「4 ä½å¹´ä»½ + 3 ä½æµæ°´ã€è‡ªåŠ¨ç”Ÿæˆï¼Œå¦‚ `2026001`;传入时后端校验格式与唯一性 |
**批次号格式规则:**
| è§„则 | è¯´æ˜Ž |
|------|------|
| é•¿åº¦ | å›ºå®š 7 ä½çº¯æ•°å­—(年份 4 ä½ + æµæ°´ 3 ä½ï¼‰ |
| ç¤ºä¾‹ | `2026001`、`2026002` â€¦ |
| å¹´ä»½èŒƒå›´ | 2000 ~ 2100 |
| æµæ°´èŒƒå›´ | 001 ~ 999,超出后后端提示「本年度生产批次号已用尽」 |
| å”¯ä¸€æ€§ | å…¨åº“唯一,包含已逻辑删除记录;重复时返回错误 |
**响应:**
- `create`:`{ "code": 0, "data": æ‰¹æ¬¡ç¼–号 }`
- `fill-info` / `update`:`{ "code": 0, "data": true }`
**错误提示:**
| åœºæ™¯ | æç¤º |
|------|------|
| æ ¼å¼ä¸æ­£ç¡® | ç”Ÿäº§æ‰¹æ¬¡å·æ ¼å¼ä¸æ­£ç¡®ï¼Œåº”为年份+3位流水号,如 2026001 |
| æ‰¹æ¬¡å·é‡å¤ | ç”Ÿäº§æ‰¹æ¬¡å·ã€2026001】已存在 |
| å½“年流水用尽 | æœ¬å¹´åº¦ç”Ÿäº§æ‰¹æ¬¡å·å·²ç”¨å°½ï¼ˆ001-999) |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| code | æ–°å¢žå¼¹çª— | å¯ç¼–辑输入框,非必填;占位提示「留空自动生成,如 2026001」 |
| code | è¡¥å½•弹窗 | å¯ç¼–辑输入框,非必填;留空自动生成 |
| code | ç¼–辑/详情弹窗 | åªè¯»å±•示,不支持修改 |
| code | åˆ—表 | æŒ‰ã€Œæ‰¹æ¬¡å·ã€åˆ—展示 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| äººå·¥è¾“入批次号 | å‰ç«¯å°†ç”¨æˆ·è¾“入值放入 `code` å­—段提交;后端校验 7 ä½æ•°å­—格式及唯一性 |
| ç•™ç©ºè‡ªåŠ¨ç”Ÿæˆ | ä¸ä¼ æˆ–传空字符串;后端取当前年份已用最大流水 + 1,格式化为 3 ä½æµæ°´ |
| ç¼–辑已有批次 | åŽç«¯ä¿ç•™åŽŸæ‰¹æ¬¡å·ï¼Œå¿½ç•¥è¯·æ±‚ä¸­çš„ `code` å˜æ›´ï¼›ä¿®æ”¹åŠ å·¥å•ä½ä¸å†é‡æ–°ç”Ÿæˆæ‰¹æ¬¡å· |
| ä¸´æ—¶æ‰¹æ¬¡è¡¥å½• | è¡¥å½•成功后才拥有正式批次号;补录前列表展示临时批次码 |
| å¹¶å‘新增 | æ‰¹æ¬¡å·åˆ—存在数据库唯一索引,重复提交会返回重复提示 |
## æ³¨æ„äº‹é¡¹
- æ–°å¢žã€è¡¥å½•弹窗的「批次号」已改为可人工输入(非必填,留空自动生成);编辑、详情弹窗为只读展示。
- å‰ç«¯æœªåšæ ¼å¼å¼ºæ ¡éªŒï¼Œä»¥æœåŠ¡ç«¯è¿”å›žçš„é”™è¯¯æç¤ºä¸ºå‡†ï¼›å¦‚éœ€å³æ—¶æ ¡éªŒï¼Œå¯æŒ‰ `^\d{7}$` ä¸”年份在 2000-2100、流水 â‰¥ 001 åˆ¤æ–­ã€‚
- æ‰¹æ¬¡å·ç”Ÿæˆä¾èµ–服务端当前年份,前端不要自行拼接年份或流水号。
- ç¼–辑弹窗回显的 `code` ä»…用于展示,提交时即使携带也不会改变已有批次号。
- å·²æœ‰åŽ†å²æ‰¹æ¬¡å·ï¼ˆæ—§çš„ PC å‰ç¼€/16 ä½éšæœºç ï¼‰ç»§ç»­ä¿ç•™ï¼Œä¸å—新规则影响;新规则仅作用于新生成或人工输入的批次号。
docs/pro_batch_pallet_code_column_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
# ç”Ÿäº§æ‰¹æ¬¡åˆ—表新增「托盘码」列 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- æ‰¹æ¬¡ç”Ÿäº§ç®¡ç† - ç”Ÿäº§æ‰¹æ¬¡åˆ—表(`/mes/pro/batch`)
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /mes/pro/batch/page | ç”Ÿäº§æ‰¹æ¬¡åˆ†é¡µï¼ˆå“åº”新增托盘码列表) |
| GET | /mes/pro/batch/get | ç”Ÿäº§æ‰¹æ¬¡è¯¦æƒ…(响应同步新增托盘码列表) |
**响应字段新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| palletCodes | String[] | è¯¥æ‰¹æ¬¡ä¸‹å…¨éƒ¨å·²ç”Ÿæˆçš„æ­£å¼æ‰˜ç›˜ç ï¼ŒæŒ‰æ‰˜ç›˜åºå·å‡åºï¼›æ— æ‰˜ç›˜æ—¶ä¸ºç©ºæ•°ç»„ |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 1024,
        "code": "2026001",
        "itemName": "塔河4号",
        "palletCodes": ["2026001-P001", "2026001-P002"]
      }
    ],
    "total": 1
  }
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| palletCodes | ç”Ÿäº§æ‰¹æ¬¡åˆ—表 | ä½äºŽã€Œæ‰¹æ¬¡å·ã€åˆ—之后、「品种名称」列之前;多个托盘码用英文逗号连接展示,超长时悬浮提示完整内容 |
| palletCodes | ç”Ÿäº§æ‰¹æ¬¡åˆ—表 | æ— æ‰˜ç›˜ç æ—¶å±•示 `-` |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ‰¹æ¬¡å·²ç»‘定/生成托盘 | è¿”回该批次下所有正式托盘码(`code` éžç©ºï¼‰ï¼ŒæŒ‰æ‰˜ç›˜åºå·å‡åº |
| æ‰¹æ¬¡ä»…有临时托盘 | ä¸´æ—¶æ‰˜ç›˜å°šæœªç”Ÿæˆæ­£å¼æ‰˜ç›˜ç ï¼Œä¸è¿”回 |
| æ‰¹æ¬¡æœªç»‘定托盘(如吨袋批次) | è¿”回空数组,列表展示 `-` |
| äºŒç»´ç é¢„生成批次 | æœªè¡¥å½•正式信息前无正式托盘码,返回空数组 |
## æ³¨æ„äº‹é¡¹
- æ‰˜ç›˜ç ä¸ºæ‰¹æ¬¡ä¸‹å¤šå€¼å­—段,前端以数组接收,展示层自行拼接,不要按单值处理。
- æŸ¥è¯¢ä¸ºæ‰¹é‡å…³è”,列表分页每页仅增加一次托盘查询,无需前端额外请求。
- è¯¦æƒ…接口返回的 `palletCodes` ä¸Žåˆ—表一致;完整的托盘信息仍通过详情中的 `pallets` å­—段获取。
- Excel å¯¼å‡ºæœªæ–°å¢žè¯¥åˆ—,若后续需要导出托盘码请另行确认格式。
docs/qc_bpm_migration_plan.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
# ç§å­è´¨æ£€ BPM è¿ç§»è®°å½•
## ç›®æ ‡æµç¨‹
以用户提供的生产流程图为准。加工部和销售部各自进行质检:初检不合格时复检,复检不合格进入复检/销毁审批并留存记录;检验完成后分别由部门领导审批,汇合后进入公司质检审批。审批通过后产品正式入库。
经销商退货进入销售部质检:不合格时复检,复检不合格不予退货并留存记录,随后进入销售部领导审批。各审批节点暂由 admin å¤„理。
## æœ¬æ¬¡ä»£ç è°ƒæ•´
- ç”Ÿäº§è´¨æ£€æŒ‰åŠ å·¥éƒ¨ã€é”€å”®éƒ¨åˆ†åˆ«å½•å…¥ï¼ŒæŽ¥å£å¿…é¡»ä¼  `qcLine=1/2`;同一袋码可形成两条独立检验记录。
- æ‰¹é‡å¤æ£€æŒ‰è´¨æ£€çº¿éš”离,提交时校验批次、质检线和流程状态一致,避免跨线覆盖实测结果。
- åˆæ£€åˆæ ¼è¿›å…¥éƒ¨é—¨å®¡æ‰¹ï¼Œåˆæ£€ä¸åˆæ ¼å…ˆå¤æ£€ï¼›éƒ¨é—¨å®¡æ‰¹å®ŒæˆåŽï¼Œä¸¤æ¡çº¿æ±‡åˆåˆ°å…¬å¸è´¨æ£€å®¡æ‰¹ã€‚退货复检结果进入销售部审批。
- BPM å›žè°ƒæŒ‰æµç¨‹å®žä¾‹ç¼–号匹配记录,审批驳回后清理对应实例编号,以便重检后重新发起。
- è´¨æ£€è®°å½•页提供 BPM è¯¦æƒ…入口;流程启动失败时可对该记录重新发起。
- ä¸‰ä»½ BPMN æ¨¡åž‹æ–‡ä»¶ä½äºŽ `yudao-module-mes/src/main/resources/bpm/`,候选人暂为当前数据库默认 admin ç”¨æˆ· ID 1。
## ä¸Šçº¿å‰ç½®å·¥ä½œ
- åœ¨ BPM æµç¨‹æ¨¡åž‹ç®¡ç†ä¸­å¯¼å…¥å¹¶å‘布三份 BPMN æ–‡ä»¶ï¼Œæµç¨‹å®šä¹‰ key å¿…须与文件中 `process id` ä¸€è‡´ã€‚可用 `scripts/deploy-seed-quality-bpm.ps1 -ApiBaseUrl <管理后台 API æ ¹åœ°å€> -AccessToken <admin ä»¤ç‰Œ>` åˆ›å»ºæˆ–更新并发布。脚本显式设置 admin(用户 ID 1)为审批人且禁用自动审批;目标环境中应先确认 admin çš„ ID ä¸º 1。
- æ‰§è¡Œ `sql/mysql/mes_seed_quality_dual_line_20261105.sql`,确保 `qc_line`、`biz_type`、流程实例字段和双线唯一索引已就绪。
- å¤„理旧的在途人工审批记录及已经产生的 BPM æµç¨‹å®žä¾‹ï¼Œé¿å…æ–°æ—§çŠ¶æ€æ··ç”¨ã€‚
- ä¸‰ä¸ª BPM æµç¨‹å‘布并完成在途数据处理后,执行 `sql/mysql/mes_qc_approver_menu_retire_20261106.sql` éšè—æ—§å®¡æ ¸äººé…ç½®èœå•。历史配置数据不删除。
- æ–°åŒçº¿è®°å½•的人工审批接口会拒绝操作并提示转到 BPM å¾…办;历史未区分质检线的记录仍保留人工接口兼容。页面隐藏新记录的批量人工审批入口。
本文是迁移工作记录;BPM æ¨¡åž‹å°šæœªåœ¨ç›®æ ‡æ•°æ®åº“发布,用户将统一负责测试。
docs/qc_seed_indicator_range_product_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
# è´¨æ£€é¡¹åˆæ ¼èŒƒå›´é…ç½®ï¼ˆæŒ‰äº§å“ç»´æŠ¤ï¼‰- å‰ç«¯è”调方案
## å˜æ›´èƒŒæ™¯
原「质量管理 > è´¨æ£€é¡¹åˆæ ¼èŒƒå›´é…ç½®ã€å°†æ‰€æœ‰äº§å“å…±ç”¨çº¯åº¦ã€å‡€åº¦ã€å‘芽率、水分四项固定配置。
现改为**按产品维护质检项**:每个质检项必须绑定一个产品(`mdm_item` ä¸­çš„启用物料,本业务即种子品种),
同一产品下质检项编码唯一,可自由新增、编辑、删除、启停和排序。
历史四项数据自动迁移为**通用默认项**(`itemId` ä¸ºç©ºï¼‰ï¼Œäº§å“æœªé…ç½®ä¸“属项时回退使用,保证存量数据与流程不受影响。
## æ¶‰åŠé¡µé¢
- è´¨é‡ç®¡ç† â†’ è´¨æ£€é¡¹åˆæ ¼èŒƒå›´é…ç½®ï¼ˆè·¯ç”± `mes/qc/seedRange/index`)
- è´¨é‡ç®¡ç† â†’ ç§å­è´¨æ£€ï¼ˆæ¡Œé¢ç«¯ï¼Œè·¯ç”± `mes/qc/seedQuality/index`)
- è´¨é‡ç®¡ç† â†’ ç§å­è´¨æ£€è®°å½•(桌面端,路由 `mes/qc/seedQuality/record`)
- ç§»åŠ¨ç«¯ â†’ ç§å­è´¨æ£€ï¼ˆ`/mobile/quality`)
- ç§»åŠ¨ç«¯ â†’ è´¨æ£€è®°å½• / æ‰¹é‡æ£€éªŒä¸Žå®¡æ ¸ï¼ˆ`/mobile/quality-records`)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. é…ç½®é¡µé€‰æ‹©äº§å“ â†’ åˆ—表只显示该产品已维护的质检项;产品未配置时显示空态并提示"质检时使用通用默认项"。
2. è´¨æ£€é¡µæ‰«ç ï¼ˆè¢‹ç  / æ‰˜ç›˜ç  / ç”Ÿäº§æ‰¹æ¬¡ç ï¼‰â†’ è§£æžæŽ¥å£ç›´æŽ¥è¿”回该产品的**生效质检项列表**(含编码、名称、单位、合格上下限)。
3. è´¨æ£€é¡µæŒ‰è¿”回的指标列表动态渲染输入项,不再固定四项、不再限制 0–100。
4. æäº¤æ—¶åŽç«¯æŒ‰"生效配置 / åŽŸè®°å½•æŒ‡æ ‡é›†åˆ"校验指标数量与编码集合,前端不得自行增删指标。
5. å¤æ£€ä½¿ç”¨åŽŸè´¨æ£€è®°å½•çš„æŒ‡æ ‡é›†åˆï¼Œé…ç½®åŽç»­å˜æ›´ä¸å½±å“åŽ†å²è®°å½•å¤æ£€ã€‚
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /mes/qc/seed-quality/ranges | æŸ¥è¯¢è´¨æ£€é¡¹é…ç½®ï¼ˆ`itemId` ä¸ºç©ºæŸ¥é€šç”¨é»˜è®¤é¡¹ï¼‰ |
| GET | /mes/qc/seed-quality/ranges/effective | æŸ¥è¯¢æŒ‡å®šäº§å“ç”Ÿæ•ˆçš„质检项 |
| POST | /mes/qc/seed-quality/ranges | æ–°å¢žè´¨æ£€é¡¹ |
| PUT | /mes/qc/seed-quality/ranges/{id} | ä¿®æ”¹è´¨æ£€é¡¹ |
| DELETE | /mes/qc/seed-quality/ranges/{id} | åˆ é™¤è´¨æ£€é¡¹ |
| POST | /mes/qc/seed-quality/ranges/copy-default?itemId= | å¤åˆ¶é€šç”¨é»˜è®¤é¡¹åˆ°æŒ‡å®šäº§å“ |
**查询参数(GET /ranges):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| itemId | Long | å¦ | äº§å“ç¼–号;不传查询通用默认项 |
| keyword | String | å¦ | è´¨æ£€é¡¹åç§°/编码模糊匹配 |
| status | Integer | å¦ | 0 å¯ç”¨ 1 åœç”¨ |
**保存参数(POST / PUT):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| itemId | Long | æ˜¯ | äº§å“ç¼–号(启用状态的物料) |
| indicatorCode | String | æ˜¯ | ç¼–码,字母开头,仅字母/数字/下划线,最长 64;同一产品下唯一 |
| indicatorName | String | æ˜¯ | è´¨æ£€é¡¹åç§° |
| unit | String | å¦ | å•位,默认 `%` |
| minValue | BigDecimal | æ˜¯ | åˆæ ¼ä¸‹é™ï¼Œâ‰¥ 0 |
| maxValue | BigDecimal | æ˜¯ | åˆæ ¼ä¸Šé™ï¼Œâ‰¥ ä¸‹é™ |
| sort | Integer | å¦ | æŽ’序,默认 0 |
| status | Integer | å¦ | 0 å¯ç”¨ 1 åœç”¨ï¼Œé»˜è®¤ 0 |
**响应字段(列表 / ç”Ÿæ•ˆåˆ—表):**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | Long | ä¸»é”® |
| itemId | Long | äº§å“ç¼–号;为空表示通用默认项 |
| indicatorCode | String | ç¼–码 |
| indicatorName | String | åç§° |
| unit | String | å•位 |
| minValue | BigDecimal | åˆæ ¼ä¸‹é™ |
| maxValue | BigDecimal | åˆæ ¼ä¸Šé™ |
| sort | Integer | æŽ’序 |
| status | Integer | çŠ¶æ€ |
| updateTime | Date | æ›´æ–°æ—¶é—´ |
**响应示例:**
```json
{
  "code": 0,
  "data": [
    { "id": 1, "itemId": 1001, "indicatorCode": "purity", "indicatorName": "纯度",
      "unit": "%", "minValue": 95, "maxValue": 100, "sort": 10, "status": 0 }
  ]
}
```
## è´¨æ£€æŽ¥å£å“åº”新增字段
`/mes/qc/seed-quality/resolve`、`/retest-scope/resolve` å“åº”新增:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| itemId | Long | äº§å“ç¼–号 |
| itemCode | String | äº§å“ç¼–码 |
| itemName | String | äº§å“åç§° |
| indicators | Array | ç”Ÿæ•ˆè´¨æ£€é¡¹ï¼š`{ code, name, unit, minValue, maxValue }` |
质检记录分页新增 `itemId / itemCode / itemName`;指标明细新增 `indicatorName / unit`。
## ç”Ÿæ•ˆè§„则
| åœºæ™¯ | è§„则 |
|------|------|
| äº§å“é…ç½®äº†å¯ç”¨çš„专属质检项 | ä½¿ç”¨è¯¥äº§å“çš„专属配置 |
| äº§å“æœªé…ç½®ä»»ä½•专属质检项 | å›žé€€ä½¿ç”¨é€šç”¨é»˜è®¤é¡¹ï¼ˆ`item_id` ä¸ºç©ºä¸”启用) |
| äº§å“å­˜åœ¨ä¸“属项但全部停用 | è§†ä¸ºæœªé…ç½®ï¼Œå›žé€€é€šç”¨é»˜è®¤é¡¹ |
| äº§å“ä¸Žé€šç”¨é»˜è®¤é¡¹éƒ½ä¸ºç©º | è´¨æ£€æŽ¥å£æŠ¥é”™"该产品尚未配置质检项,请联系管理员维护" |
| æŒ‡æ ‡æ ¡éªŒ | æäº¤çš„æŒ‡æ ‡ç¼–码集合必须与生效配置完全一致,数量、编码不得增删 |
| æ•°å€¼æ ¡éªŒ | å®žæµ‹å€¼ã€ä¸Šä¸‹é™å‡ â‰¥ 0,下限 â‰¤ ä¸Šé™ï¼›å®žæµ‹å€¼åœ¨åŒºé—´å†…判定合格 |
| å¤æ£€ | æŒ‰åŽŸè´¨æ£€è®°å½•çš„æŒ‡æ ‡é›†åˆæ ¡éªŒï¼Œä¸ä½¿ç”¨å½“å‰é…ç½® |
## è¿ç§»æ­¥éª¤
1. æ‰§è¡Œ `sql/mysql/mes_qc_seed_indicator_range_product_20261105.sql`(幂等,可重复执行)。
2. è„šæœ¬ä¼šæŠŠåŽŸå››é¡¹æ ‡è®°ä¸ºé€šç”¨é»˜è®¤é¡¹ï¼ˆ`item_id` ä¸ºç©ºï¼‰ï¼Œä¸å½±å“ä»»ä½•存量质检流程。
3. å„产品可在配置页通过"复制通用默认项"快速生成专属配置,再按需调整。
## æ³¨æ„äº‹é¡¹
- è´¨æ£€é¡¹ç¼–码在同一产品下唯一;编辑时编码不可修改,如需更换请删除后新增。
- äº§å“ä¸‹æ‹‰åªæ˜¾ç¤ºå¯ç”¨çŠ¶æ€çš„ç‰©æ–™ï¼Œè¯·ç¡®ä¿å¾…é…ç½®çš„ç§å­å“ç§å·²ç»´æŠ¤ä¸ºå¯ç”¨ç‰©æ–™ã€‚
- å‰ç«¯ä¸å†å¯¹æŒ‡æ ‡åš 0–100 çš„硬性限制,改为非负 + ä¸‹é™ â‰¤ ä¸Šé™ï¼›è¯·å‹¿åœ¨å‰ç«¯è‡ªè¡Œè¿‡æ»¤æˆ–补全指标。
- ç§»åŠ¨ç«¯ä¸Žæ¡Œé¢ç«¯å‡ä»¥è§£æžæŽ¥å£è¿”å›žçš„ `indicators` ä¸ºå‡†ï¼Œä¸å†å•独调用范围配置接口。
- åˆ é™¤è´¨æ£€é¡¹åªå½±å“åŽç»­è´¨æ£€ï¼ŒåŽ†å²è®°å½•ä¸­çš„æŒ‡æ ‡åç§°/单位以快照形式保存,不受影响。
docs/qc_seed_quality_batch_fold_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
# è´¨æ£€è®°å½•按生产批次折叠展示 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- è´¨é‡ç®¡ç† > è´¨æ£€è®°å½•(`/qc/seed-quality-record`)
## é¡µé¢ç»“æž„
1. é¡¶éƒ¨é¡µå¤´ä¸Žç»“果统计卡片保持原样。
2. è®°å½•区域由「整表平铺」改为「按生产批次折叠」:
   - æ¯ä¸ªç”Ÿäº§æ‰¹æ¬¡ä¸ºä¸€å¼ å¯æŠ˜å åˆ†ç»„卡片,默认展开第一条。
   - å¡ç‰‡å¤´éƒ¨å±•示批次号、品种、加工单位、生产日期、最近检验时间,以及合格/不合格/处理中/待审核统计。
   - å±•开后展示该批次下的质检明细表(分页、勾选、批量操作仍可用)。
3. åº•部为「批次分页」,按批次翻页;明细分页在每张卡片内部独立维护。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /mes/qc/seed-quality/batch-page | æ–°å¢žï¼šè´¨æ£€è®°å½•按生产批次分组分页(批次汇总) |
| GET | /mes/qc/seed-quality/page | åŽŸæœ‰ï¼šè´¨æ£€æ˜Žç»†åˆ†é¡µï¼Œæ–°å¢žä¸¤ä¸ªå¯é€‰è¿‡æ»¤å‚æ•° |
### 1. æ‰¹æ¬¡åˆ†ç»„分页
`GET /mes/qc/seed-quality/batch-page`
**请求参数(与明细分页一致,均可选):**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| pageNo | Integer | å¦ | æ‰¹æ¬¡é¡µç ï¼Œé»˜è®¤ 1 |
| pageSize | Integer | å¦ | æ¯é¡µæ‰¹æ¬¡æ•°ï¼Œé»˜è®¤ 10,最大 100 |
| code | String | å¦ | è¢‹ç ï¼ˆæ”¯æŒæ‰«ç å†…容,后端自动解析为袋码) |
| batchCode | String | å¦ | ç”Ÿäº§æ‰¹æ¬¡å·ï¼ˆæ¨¡ç³ŠåŒ¹é…ï¼‰ |
| result | Integer | å¦ | æ£€éªŒç»“果:1 åˆæ ¼ï¼Œ2 ä¸åˆæ ¼ï¼Œ0 å¤„理中 |
| workflowStatus | Integer | å¦ | æµç¨‹çŠ¶æ€ï¼Œå–å€¼è§ä¸‹è¡¨ |
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| batchId | Long | ç”Ÿäº§æ‰¹æ¬¡ç¼–号,未绑定批次时为空 |
| batchCode | String | ç”Ÿäº§æ‰¹æ¬¡å·ï¼Œæœªç»‘定时为空字符串 |
| batchLabel | String | æ‰¹æ¬¡å±•示名,未绑定时为「未绑定批次」 |
| itemId | Long | å“ç§ç¼–号 |
| itemCode | String | å“ç§ç¼–码 |
| itemName | String | å“ç§åç§° |
| lineName | String | åŠ å·¥å•ä½ï¼ˆäº§çº¿åç§°ï¼‰ |
| produceDate | LocalDateTime | ç”Ÿäº§æ—¥æœŸ |
| totalCount | Integer | ç»„内质检记录总数 |
| passCount | Integer | åˆæ ¼æ•°é‡ |
| failCount | Integer | ä¸åˆæ ¼æ•°é‡ |
| processingCount | Integer | å¤„理中数量 |
| pendingRetestCount | Integer | å¾…复检/重新初检数量(10、50) |
| pendingProcessReviewCount | Integer | å¾…初检审核数量(20、25) |
| pendingCompanyReviewCount | Integer | å¾…复检审核数量(30、60) |
| releasedCount | Integer | å·²æ”¾è¡Œæ•°é‡ï¼ˆ40) |
| scrappedCount | Integer | å·²æŠ¥åºŸæ•°é‡ï¼ˆ70) |
| lastInspectedAt | LocalDateTime | ç»„内最近检验时间,用于排序与展示 |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "batchId": 1024,
        "batchCode": "2026001",
        "batchLabel": "2026001",
        "itemCode": "SEED001",
        "itemName": "塔河4号",
        "lineName": "一号加工线",
        "produceDate": "2026-11-01 08:00:00",
        "totalCount": 120,
        "passCount": 80,
        "failCount": 10,
        "processingCount": 30,
        "pendingRetestCount": 5,
        "pendingProcessReviewCount": 15,
        "pendingCompanyReviewCount": 10,
        "releasedCount": 80,
        "scrappedCount": 10,
        "lastInspectedAt": "2026-11-05 09:30:00"
      }
    ],
    "total": 1
  }
}
```
### 2. æ˜Žç»†åˆ†é¡µæ–°å¢žå‚æ•°
`GET /mes/qc/seed-quality/page`
在原有参数基础上新增:
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| exactBatchCode | String | å¦ | ç”Ÿäº§æ‰¹æ¬¡å·ç²¾ç¡®åŒ¹é…ï¼Œå±•开某个批次时传入 |
| unbound | Boolean | å¦ | ä»…查询未绑定生产批次的记录,展开「未绑定批次」时传 true |
**响应字段无变化。**
## æŽ¥å£è°ƒç”¨æ—¶åº
1. é¡µé¢åŠ è½½æˆ–ç­›é€‰æŸ¥è¯¢ï¼šè°ƒç”¨ `batch-page` èŽ·å–å½“å‰é¡µæ‰¹æ¬¡æ±‡æ€»ã€‚
2. é»˜è®¤å±•开第一条批次:调用 `page`,传 `exactBatchCode`(或 `unbound=true`)+ å½“前筛选条件。
3. å±•开其他批次:按需调用 `page`,每个批次维护独立的页码与每页条数。
4. æ‰¹æ¬¡ç¿»é¡µï¼šé‡æ–°è°ƒç”¨ `batch-page`。
5. å¤æ£€ã€å®¡æ ¸ã€æµç¨‹è®°å½•等操作沿用原接口,操作成功后重新加载批次汇总及已展开批次的明细。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| batchLabel | æ‰¹æ¬¡å¡ç‰‡å¤´ | æ‰¹æ¬¡ä¸»æ ‡é¢˜ï¼Œæœªç»‘定批次显示「未绑定批次」 |
| itemName / lineName / produceDate / lastInspectedAt | æ‰¹æ¬¡å¡ç‰‡å¤´ | å‰¯æ ‡é¢˜ä¿¡æ¯ï¼Œç¼ºå¤±æ—¶éšè— |
| totalCount / passCount / failCount / processingCount | æ‰¹æ¬¡å¡ç‰‡å¤´ | ç»Ÿè®¡èƒ¶å›Šï¼Œå§‹ç»ˆå±•示 |
| pendingProcessReviewCount / pendingCompanyReviewCount / pendingRetestCount / releasedCount / scrappedCount | æ‰¹æ¬¡å¡ç‰‡å¤´ | çŠ¶æ€æ ‡ç­¾ï¼Œæ•°é‡ä¸º 0 æ—¶éšè— |
| è´¨æ£€ç¼–号、袋码、托盘码、检验结果、流程状态、不合格原因、检验时间、提交时间、操作 | å±•开后的明细表 | æ˜Žç»†è¡¨å†…不再重复展示生产批次列 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| åˆ†ç»„依据 | æŒ‰è´¨æ£€è®°å½•çš„ `batch_code` åˆ†ç»„,同一批次的所有袋码质检记录归为一组 |
| æœªç»‘定批次 | `batch_code` ä¸ºç©ºçš„记录归入「未绑定批次」组,排在有批次记录之后 |
| æŽ’序 | æŒ‰ç»„内最近检验时间倒序,其次按组内最大记录编号倒序 |
| ç­›é€‰ | è¢‹ç ã€æ‰¹æ¬¡å·ã€æ£€éªŒç»“果、流程状态筛选同时作用于分组统计与明细 |
| ç»Ÿè®¡å£å¾„ | ç»Ÿè®¡æ•°é‡å‡åŸºäºŽå½“前筛选条件下的记录,不等于批次全部袋码数 |
| åˆ†é¡µ | æ‰¹æ¬¡åˆ†é¡µä¸Žæ˜Žç»†åˆ†é¡µç›¸äº’独立,批次分页改变后明细重新加载 |
## æ³¨æ„äº‹é¡¹
- æ‰¹æ¬¡åˆ†ç»„接口返回的是汇总数据,不包含明细;明细必须通过 `page` æŽ¥å£æŒ‰æ‰¹æ¬¡åŠ è½½ã€‚
- å±•开批次时不要复用「生产批次」模糊筛选值,必须使用 `exactBatchCode` æˆ– `unbound`,否则会串入其他批次数据。
- `processingCount` ä¸ºç»“果为空或 0 çš„记录数,与「处理中」展示口径一致。
- å‹¾é€‰è®°å½•跨批次保留,批量操作仍按流程状态与生产批次分组呈现。
- è‹¥åŽç»­æ–°å¢žè´¨æ£€è®°å½•字段,分组统计无需调整;若新增流程状态,需要同步补充统计字段与分组头标签。
docs/qc_seed_quality_dual_line_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
# ç§å­è´¨æ£€åŒçº¿æµç¨‹ï¼ˆå«ç»é”€å•†é€€è´§è´¨æ£€ï¼‰- å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- ç§å­è´¨æ£€æäº¤é¡µï¼ˆ`views/mes/qc/seedQuality/index.vue`)
- ç§å­è´¨æ£€è®°å½•页(`views/mes/qc/seedQuality/record.vue`)
- é”€å”®é€€è´§å•列表页(`views/wls/returnsales/index.vue`)及退货质检弹窗
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. åŠ å·¥éƒ¨å¼€é”€å”®å…¥åº“ï¼šè¢‹ç å…¥åº“åŽå¯æ‰«ç è´¨æ£€ï¼Œæ‰˜ç›˜ç æ¯ 48 è¢‹æˆ– 100 è¢‹ç”Ÿæˆï¼Œæ‰¹æ¬¡ç å¸¦å‡ºæ•´æ‰¹è¢‹æ•°ã€‚
2. **一次种子质检提交即同时生成两条质检记录**:`qcLine=1`(加工部)与 `qcLine=2`(销售部),两条记录的指标、结论完全一致;后续复检、审批分别按质检线独立进行。
3. åˆæ£€åˆæ ¼ç›´æŽ¥è¿›å…¥æœ¬çº¿é¢†å¯¼ BPM å®¡æ‰¹ï¼›åˆæ£€ä¸åˆæ ¼å…ˆå¤æ£€ï¼Œå†è¿›å…¥æœ¬çº¿é¢†å¯¼å®¡æ‰¹ã€‚部门审批驳回后重新初检。
4. ä¸¤æ¡çº¿åˆ†åˆ«ä½¿ç”¨æµç¨‹å®šä¹‰ `mes_seed_quality_line_approve`,businessKey ä¸º `质检线#生产批次号`;审批通过后进入公司质检待审状态。
5. åŒä¸€ç”Ÿäº§æ‰¹æ¬¡å†…所有袋的两条线都完成部门审批后,系统自动发起**一次**公司质检审批(流程定义 `mes_seed_quality_company_approve`,businessKey ä¸º**生产批次号**)。公司审批**按批次发起、按袋落库**:每个袋两条线都为合格才放行入库,任一条不合格则该袋报废;驳回则整批两条线一起退回待复检。
6. æ”¾è¡ŒåŽäº§å“æ­£å¼å…¥åº“,后续库管扫码出库、销售转移经销商、终端农户扫码溯源。
7. ç»é”€å•†é€€è´§åˆ†æ”¯ï¼šé”€å”®é€€è´§å•确认后发起退货质检 â†’ å¤æ£€ â†’ é”€å”®éƒ¨é¢†å¯¼å®¡æ‰¹ï¼›ä¸åˆæ ¼åˆ™é€€è´§å•自动取消、不予退货并保留检验记录。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|---|---|---|
| GET | `/mes/qc/seed-quality/resolve` | è¯†åˆ«è¢‹ç /托盘码/批次码,返回待检清单与已有质检线 |
| POST | `/mes/qc/seed-quality/submit-group` | æŒ‰è¢‹ç ã€æ‰˜ç›˜ç ã€æ‰¹æ¬¡ç æäº¤åˆæ£€ï¼Œä¸€æ¬¡ç”ŸæˆåŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½• |
| POST | `/mes/qc/seed-quality/submit` | å•袋提交初检,一次生成加工部、销售部两条记录 |
| GET | `/mes/qc/seed-quality/page` | è´¨æ£€è®°å½•分页(新增 `qcLine`、`bizType` ç­›é€‰ä¸Žè¿”回字段) |
| GET | `/mes/qc/seed-quality/batch-page` | æŒ‰ç”Ÿäº§æ‰¹æ¬¡åˆ†ç»„的质检汇总 |
| POST | `/mes/qc/seed-quality/process-approve` | åˆæ£€å®¡æ ¸ï¼ˆåŠ å·¥éƒ¨/销售部领导;退货质检的销售部审批复用此接口) |
| POST | `/mes/qc/seed-quality/process-approve-batch` | æ‰¹é‡åˆæ£€å®¡æ ¸ |
| POST | `/mes/qc/seed-quality/company-approve` | å…¬å¸è´¨æ£€å®¡æ‰¹ï¼ˆæŒ‰è¢‹åˆå¹¶ä¸¤æ¡çº¿å¤„理) |
| POST | `/mes/qc/seed-quality/company-approve-batch` | æ‰¹é‡å…¬å¸è´¨æ£€å®¡æ‰¹ï¼ˆæŒ‰è¢‹ç åˆ†ç»„合并) |
| POST | `/mes/qc/seed-quality/retest` | å•条记录复检/重新初检 |
| POST | `/mes/qc/seed-quality/retest-batch` | æŒ‰è®°å½• ID æ‰¹é‡å¤æ£€ |
| GET | `/mes/qc/seed-quality/retest-scope/resolve` | æŒ‰æ‰˜ç›˜ç /批次码解析待复检与待审核清单 |
| POST | `/mes/qc/seed-quality/retest-scope` | æŒ‰æ‰˜ç›˜ç /批次码批量复检或重新初检 |
| GET | `/mes/qc/seed-quality/return/resolve` | è§£æžç»é”€å•†é€€è´§å•待质检信息 |
| POST | `/mes/qc/seed-quality/return/submit` | æäº¤ç»é”€å•†é€€è´§è´¨æ£€ |
| POST | `/mes/qc/seed-quality/return/approve` | ç»é”€å•†é€€è´§è´¨æ£€é”€å”®éƒ¨é¢†å¯¼å®¡æ‰¹ |
### å“åº”字段新增
**`/resolve`(`SeedQualityScan`)新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| qualityIds | Number[] | è¯¥è¢‹å·²å­˜åœ¨çš„全部质检记录编号 |
| qualityLines | Object[] | å·²å­˜åœ¨è´¨æ£€çº¿ï¼š`{ id, qcLine, workflowStatus }`(不含退货质检记录) |
**`/page`(`SeedQualityRecord`)新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| qcLine | Number | è´¨æ£€çº¿ï¼š1 åŠ å·¥éƒ¨ï¼Œ2 é”€å”®éƒ¨ï¼Œ0 æœªåŒºåˆ†ï¼ˆåŽ†å²æ•°æ®ï¼‰ |
| bizType | Number | ä¸šåŠ¡ç±»åž‹ï¼š1 ç”Ÿäº§å…¥åº“质检,2 ç»é”€å•†é€€è´§è´¨æ£€ |
| processInstanceId | String | æœ¬çº¿ BPM æµç¨‹å®žä¾‹ |
| companyProcessInstanceId | String | å…¬å¸è´¨æ£€ BPM æµç¨‹å®žä¾‹ |
| returnSalesId / returnSalesCode | Number / String | å…³è”销售退货单 |
| retestedAt / processApprovedAt / lineFinishedAt | String | å¤æ£€ã€åˆæ£€å®¡æ ¸ã€æœ¬çº¿å®Œæˆæ—¶é—´ |
| companySubmittedAt / companyApprovedAt | String | å…¬å¸å®¡æ‰¹å‘起、完成时间 |
**请求参数新增:**
`/page`、`/batch-page` æ”¯æŒ `qcLine`、`bizType` ç­›é€‰ã€‚
**`/return/resolve` å“åº”:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| returnSalesId / returnSalesCode / returnSalesStatus | Number / String / Number | é€€è´§å•信息 |
| itemId / batchId / batchCode | Number / Number / String | ä»Žé€€è´§å•明细带出 |
| indicators | Object[] | è¯¥äº§å“ç”Ÿæ•ˆè´¨æ£€é¡¹ `{ code, name, unit, minValue, maxValue }` |
| qualityId / result / workflowStatus | Number | å·²å­˜åœ¨è´¨æ£€è®°å½•时返回,用于禁止重复提交 |
**`/return/submit` è¯·æ±‚:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| returnSalesId | Number | æ˜¯ | é”€å”®é€€è´§å•编号 |
| indicators | Object[] | æ˜¯ | `{ code, value, minValue, maxValue }`,须覆盖全部生效质检项 |
| reason | String | ä¸åˆæ ¼æ—¶å¿…å¡« | ä¸åˆæ ¼åŽŸå›  |
## æµç¨‹çŠ¶æ€è¯´æ˜Ž
| çŠ¶æ€å€¼ | å«ä¹‰ | å¤„理人/触发方式 |
|---|---|---|
| 10 | å¾…复检 | åŠ å·¥éƒ¨/销售部质检员 |
| 20 | å¾…部门审批 | æœ¬çº¿é¢†å¯¼ BPM å¾…办 |
| 25 | å¾…部门不合格审批 | æœ¬çº¿é¢†å¯¼ BPM å¾…办 |
| 30 | å¾…公司质检审批 | å…¬å¸è´¨æ£€ï¼ˆåŒçº¿éƒ¨é—¨å®¡æ‰¹å®ŒæˆåŽè‡ªåŠ¨å‘èµ·ï¼‰ |
| 40 | å·²æ”¾è¡Œ | å…¬å¸å®¡æ‰¹é€šè¿‡ï¼Œåº“存转入合格库 |
| 50 | åˆæ£€é©³å›žå¾…重检 | æœ¬çº¿é¢†å¯¼é©³å›žåŽ |
| 60 | å¾…公司质检不合格处理 | å…¬å¸è´¨æ£€ï¼ˆåŒçº¿éƒ¨é—¨å®¡æ‰¹å®ŒæˆåŽè‡ªåŠ¨å‘èµ·ï¼‰ |
| 70 | å·²æŠ¥åºŸ | å…¬å¸å®¡æ‰¹ç¡®è®¤ä¸åˆæ ¼ |
经销商退货质检复用同一组状态:初检合格 â†’ 20(待销售部领导审批),不合格 â†’ 10(待复检);复检后无论结论均进入 20 å¹¶å‘起销售部审批。审批通过且检验合格 â†’ 40,退货单进入“待执行”;审批驳回或检验不合格 â†’ 70,退货单取消。
## BPM æµç¨‹å®šä¹‰
| æµç¨‹å®šä¹‰ key | businessKey | æµç¨‹å˜é‡ |
|---|---|---|
| `mes_seed_quality_line_approve` | `{qcLine}#{生产批次号}` | `qcLine`、`batchCode` |
| `mes_seed_quality_company_approve` | ç”Ÿäº§æ‰¹æ¬¡å· | `batchCode`、`code`(兼容旧流程)、`passed` |
| `mes_seed_return_approve` | é”€å”®é€€è´§å•编号 | `returnSalesId`、`returnSalesCode`、`code` |
- æµç¨‹ç›‘听器在流程结束(通过/驳回)时回写质检状态;取消(CANCEL)不改变业务状态。
- è‹¥ BPM æµç¨‹å®šä¹‰æœªéƒ¨ç½²ï¼Œå‘起流程会失败并记录日志;修复配置后可通过质检记录页的“重新发起审批”恢复。新双线记录不能使用人工审批接口。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|---|---|---|
| qcLine | è®°å½•列表、详情 | åŠ å·¥éƒ¨/销售部标签;历史数据显示“未区分” |
| bizType | è®°å½•列表 | é€€è´§è´¨æ£€è®°å½•显示“退货”标签与退货单号 |
| workflowStatus | è®°å½•列表、详情 | æŒ‰ä¸šåŠ¡ç±»åž‹å±•ç¤ºï¼šé€€è´§çŠ¶æ€ 20 æ˜¾ç¤ºâ€œå¾…销售部领导审批” |
| returnSalesCode | è®°å½•列表 | é€€è´§è´¨æ£€å…³è”的经销商退货单 |
| processInstanceId / companyProcessInstanceId | è¯¦æƒ… | å±•示对应的 BPM å®¡æ‰¹å®žä¾‹ï¼ˆå¯è·³è½¬æµç¨‹è¯¦æƒ…) |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|---|---|
| åˆæ£€æäº¤ | æ¯è¢‹å›ºå®šç”ŸæˆåŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•ï¼ŒæŒ‡æ ‡ä¸Žç»“è®ºå®Œå…¨ä¸€è‡´ |
| é‡å¤æäº¤ | åŒä¸€è¢‹ç å·²æœ‰ä»»æ„ä¸€æ¡ç”Ÿäº§å…¥åº“质检记录时拒绝再次提交 |
| çº¿æ¡å®¡æ‰¹ | æ¯æ¡çº¿ç‹¬ç«‹å®¡æ‰¹ï¼›BPM businessKey æŒ‰ `质检线#生产批次号` å½’并,同批次同线条一次审批全部待审记录 |
| å…¬å¸å®¡æ‰¹ï¼ˆBPM) | æŒ‰ç”Ÿäº§æ‰¹æ¬¡åªå‘起一次:批次内所有生产入库质检记录都处于 30/60 æ—¶æ‰å‘起;流程 businessKey ä¸ºç”Ÿäº§æ‰¹æ¬¡å·ï¼›æµç¨‹å®ŒæˆåŽæŒ‰è¢‹è½åº“,每个袋两条线都为 30 æ—¶æ”¾è¡Œï¼Œå¦åˆ™æŠ¥åºŸï¼›é©³å›žæ—¶æ•´æ‰¹ä¸¤æ¡çº¿ä¸€èµ·å›žåˆ° 10(待复检) |
| å…¬å¸å®¡æ‰¹ï¼ˆäººå·¥æŽ¥å£ï¼‰ | ä»æŒ‰è¢‹å¤„理:必须两条线都处于 30/60;两条线都为 30 æ—¶æ”¾è¡Œï¼Œå¦åˆ™æŠ¥åºŸï¼›é©³å›žæ—¶ä¸¤æ¡çº¿ä¸€èµ·å›žåˆ° 10(待复检) |
| åº“存流转 | å…¬å¸å®¡æ‰¹é€šè¿‡åŽæ•´è¢‹è½¬å…¥åˆæ ¼åº“;报废时不入合格库,保留质检与审批痕迹 |
| é€€è´§è´¨æ£€ | é€€è´§å•状态为草稿或待检验时可提交;不合格自动取消退货单并保留质检记录 |
| æƒé™ | åˆæ£€æäº¤ `mes:qc-seed-quality:submit`;复检 `mes:qc-seed-quality:retest`;本线/销售部审批 `mes:qc-seed-quality:process-approve`;公司审批 `mes:qc-seed-quality:company-approve` |
## æ•°æ®åº“变更
执行脚本:`sql/mysql/mes_seed_quality_dual_line_20261105.sql`
- æ–°å¢žå­—段:`qc_line`、`biz_type`、`process_instance_id`、`company_process_instance_id`、`line_finished_at`、`company_submitted_at`、`return_sales_id`、`return_sales_code`。
- åŽŸå”¯ä¸€ç´¢å¼• `uk_code` è°ƒæ•´ä¸º `uk_code_line(code, qc_line)`,允许同一袋码存在两条记录。
- åŽ†å²æ•°æ® `biz_type` å›žå¡«ä¸º 1(生产入库质检),`qc_line` ä¸º 0(未区分)。
## æ³¨æ„äº‹é¡¹
- å‰ç«¯åˆ—表需按“袋码 + è´¨æ£€çº¿â€å±•示,避免把同一袋的两条记录当成两袋统计;批次汇总接口已按袋码去重。
- å…¬å¸å®¡æ‰¹å³ä½¿åªé€‰ä¸­ä¸€æ¡çº¿è®°å½•,后端也会处理整袋两条线,前端提示需说明“按袋合并处理”。
- å¤æ£€ã€åˆæ£€å®¡æ ¸æŒ‰è®°å½• ID æ“ä½œæ—¶åªå½±å“æ‰€é€‰è®°å½•所在线条;需要双线一起处理请使用公司审批。
- ç»é”€å•†é€€è´§è´¨æ£€å…¥å£ä½äºŽâ€œé”€å”®é€€è´§å•”列表,仅“待检验”状态显示;提交后审批在“质检记录”页完成。
- é€€è´§å•被取消或质检不合格后,不得再次提交质检。
docs/scan_event_pallet_code_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
# æ‰«ç äº‹ä»¶åŒæ—¶å±•示托盘码与批次码 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- MES / ä¸€è¢‹ä¸€ç ç®¡ç† / ç”Ÿäº§çº¿æ‰«ç å…¥åº“(`/bagcode/scan-in`)
- MES / æ‰«ç äº‹ä»¶æŸ¥è¯¢é¡µï¼ˆå¦‚展示扫码事件列表,可选同步)
## ä¸šåŠ¡è¯´æ˜Ž
扫码事件的「追溯码」是实际扫描到的码值,按扫码类型不同可能是袋码、托盘码或批次码,单看该列无法区分托盘与批次。本次在响应中补充托盘码,使列表可以同时展示「托盘码」与「批次码」。
| æ‰«ç ç±»åž‹ | è¿½æº¯ç ï¼ˆbagCode) | æ‰˜ç›˜ç ï¼ˆpalletCode) | æ‰¹æ¬¡ç ï¼ˆbatchCode) |
|----------|-------------------|----------------------|---------------------|
| è¢‹ç  BAG | æ‰«æçš„袋码/业务码 | è¯¥è¢‹æ‰€å±žæ‰˜ç›˜ç  | è¢‹æ‰€å±žæ‰¹æ¬¡å· |
| æ‰˜ç›˜ç  PALLET | æ‰«æçš„æ‰˜ç›˜ç /业务码/临时码 | æ‰˜ç›˜æ­£å¼ç ï¼ˆæ— æ­£å¼ç æ—¶å›žé€€ä¸´æ—¶ç ï¼‰ | æ‰˜ç›˜æ‰€å±žæ‰¹æ¬¡å· |
| æ‰¹æ¬¡ç  BATCH | æ‰«æçš„æ‰¹æ¬¡ç  | ç©ºï¼ˆæ•´æ‰¹å¯èƒ½è·¨å¤šä¸ªæ‰˜ç›˜ï¼Œä¸å½’属单一托盘) | æ‰¹æ¬¡å· |
托盘码为读取时实时反查得出:
- æ‰˜ç›˜ç äº‹ä»¶æŒ‰æ‰«ç å†…容反查托盘(兼容业务码、正式托盘码、临时托盘码);
- è¢‹ç äº‹ä»¶æŒ‰è¢‹ç ç¼–号或码值反查袋码,再取其所属托盘;
- å› æ­¤åŽ†å²æ‰«ç äº‹ä»¶åŒæ ·èƒ½å±•ç¤ºæ‰˜ç›˜ç ï¼Œæ— éœ€æ•°æ®è¿ç§»ã€‚
## API
### 1. æ‰«ç äº‹ä»¶åˆ†é¡µ
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/pro/scan-in/page` | æ‰«ç äº‹ä»¶åˆ†é¡µï¼ˆæƒé™ `mes:pro-scan-in:query`) |
**响应字段新增(`data.list[]`):**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| palletId | Long | æ‰˜ç›˜ç¼–号,无法归属托盘时为空 |
| palletCode | String | æ‰˜ç›˜ç ï¼Œæ— æ³•归属托盘时为空(批次扫码固定为空) |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 20,
        "bagCode": "3508118133307512",
        "scanType": "PALLET",
        "palletId": 13708,
        "palletCode": "3508118133307512",
        "batchCode": "2026002",
        "status": "SUCCESS"
      }
    ],
    "total": 1
  }
}
```
### 2. æœ€æ–°æ‰«ç äº‹ä»¶ / çœ‹æ¿
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/pro/scan-in/latest` | æœ€æ–°æ‰«ç äº‹ä»¶ï¼ˆæƒé™ `mes:pro-scan-in:query`) |
| GET | `/mes/pro/scan-in/dashboard` | æ‰«ç çœ‹æ¿ï¼ˆ`data.latest` åŒæ ·åŒ…含新字段) |
### 3. å…¶ä»–返回扫码事件的接口
`/mes/pro/scan-in/my-page`、`/mes/pro/scan-in/my-event`、`/mes/pro/scan-in/inbound` è¿”回结构一致,均包含 `palletId`、`palletCode`。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| bagCode | åˆ—表 | ä¿ç•™åŽŸæœ‰ã€Œè¿½æº¯ç ã€åˆ—ï¼Œå±•ç¤ºå®žé™…æ‰«æç å€¼ |
| palletCode | åˆ—表 | æ–°å¢žã€Œæ‰˜ç›˜ç ã€åˆ—,空值显示 `-` |
| batchCode | åˆ—表 | åˆ—名由「生产批次」调整为「批次码」,空值显示 `-` |
## æ³¨æ„äº‹é¡¹
- æ‰¹æ¬¡ç æ‰«ç äº‹ä»¶ä¸å½’属单一托盘,`palletCode` ä¸ºç©ºå±žæ­£å¸¸çŽ°è±¡ï¼Œå‰ç«¯æ˜¾ç¤º `-` å³å¯ã€‚
- æ‰˜ç›˜ç ä¸ºå®žæ—¶åæŸ¥ç»“果,若托盘被逻辑删除,历史事件可能展示为空。
- æœ¬æ”¹åŠ¨ä¸æ¶‰åŠæ•°æ®åº“è¡¨ç»“æž„å˜æ›´ï¼Œæ— éœ€æ‰§è¡Œ SQL è„šæœ¬ã€‚
docs/scan_in_multi_type_inbound.md
@@ -28,8 +28,8 @@
| eventId | String | æ˜¯ | - | äº‹ä»¶å·ï¼ˆå»ºè®®ç”¨ `{scanType}-{时间戳}` ä¿è¯å”¯ä¸€ï¼‰ |
| quantity | Number | æ˜¯ | - | è¢‹ç åœºæ™¯ä¼  1;托盘/批次场景后端忽略该值,按已装袋数/批次袋数计算 |
| lineCode | String | å¦ | - | äº§çº¿ç¼–码(前端下拉选择) |
| locationId | Long | æ˜¯ | - | WIP æš‚存库区编号(前端下拉选择) |
| areaId | Long | æ˜¯ | - | WIP æš‚存库位编号(前端下拉选择) |
| locationId | Long | å¦ | æ”¹ä¸ºéžå¿…å¡« | WIP æš‚存库区编号(仓库移除库区/库位后前端不再传) |
| areaId | Long | å¦ | æ”¹ä¸ºéžå¿…å¡« | WIP æš‚存库位编号(前端不再传) |
**响应新增字段:**
docs/system_appearance_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
# ç³»ç»Ÿå¤–观设置(登录页背景图 + ä¼ä¸š Logo)- å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- ç³»ç»Ÿç®¡ç† â†’ å¤–观设置(`system/appearance/index`)
- ç™»å½•页(品牌 Logo、登录背景图)
- é¦–页(侧边栏企业 Logo)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. ç®¡ç†å‘˜åœ¨ã€Œå¤–观设置」页上传登录页背景图 / ä¼ä¸š Logo,图片通过公共上传接口直接获得永久访问地址与 `blobId`。
2. ç‚¹å‡»ä¿å­˜åŽï¼ŒåŽç«¯æŠŠ `blobId` ä¸Žå›ºå®šè®°å½•(`recordType = system_appearance`、`recordId = 1`)绑定,并分别以 `application = logo`、`application = login_background` åŒºåˆ†ä¸¤ç±»å›¾ç‰‡ã€‚
3. å‰ç«¯ä¿å­˜æˆåŠŸåŽç«‹å³åº”ç”¨ï¼šç™»å½•é¡µèƒŒæ™¯ã€ç™»å½•é¡µ Logo、首页侧边栏 Logo å®žæ—¶å˜åŒ–。
4. æµè§ˆå™¨åˆ·æ–°æˆ–重新登录时,前端在应用启动阶段调用「获得系统外观」接口,读取服务端配置并应用;未登录的登录页也能显示已配置的背景图与 Logo。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž | æƒé™ |
|------|------|------|------|
| GET | /system/appearance/get | èŽ·å¾—ç³»ç»Ÿå¤–è§‚é…ç½® | åŒ¿åå¯è®¿é—®ï¼ˆ@PermitAll) |
| POST | /system/appearance/save | ä¿å­˜ç³»ç»Ÿå¤–观配置 | `system:appearance:update` |
| POST | /system/storage-blob/public-upload | å…¬å…±æ–‡ä»¶ä¸Šä¼ ï¼ˆæ°¸ä¹…有效) | ç™»å½•用户 |
| GET | /system/storage-blob/preview/{fileName}?publicKey=xxx | å›¾ç‰‡é¢„览(永久地址) | åŒ¿åå¯è®¿é—®ï¼ˆ@PermitAll) |
### èŽ·å¾—ç³»ç»Ÿå¤–è§‚
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| logoBlobId | Long | ä¼ä¸š Logo çš„æ–‡ä»¶ç¼–号,未配置为 null |
| logoUrl | String | ä¼ä¸š Logo çš„æ°¸ä¹…访问地址,未配置为 null |
| loginBackgroundBlobId | Long | ç™»å½•背景图的文件编号,未配置为 null |
| loginBackgroundUrl | String | ç™»å½•背景图的永久访问地址,未配置为 null |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "logoBlobId": 1024,
    "logoUrl": "http://192.168.0.10:48080/admin-api/system/storage-blob/preview/xxx.png?publicKey=yyy",
    "loginBackgroundBlobId": 1025,
    "loginBackgroundUrl": "http://192.168.0.10:48080/admin-api/system/storage-blob/preview/zzz.png?publicKey=www"
  },
  "msg": ""
}
```
### å…¬å…±æ–‡ä»¶ä¸Šä¼ 
- è¯·æ±‚方式:`multipart/form-data`
- å‚数:`files`(可多个文件,本场景一次一个文件)
- å“åº”为文件数组,本场景取第一个元素,使用其中的 `id`(即 `blobId`)与 `previewURL`(永久访问地址)。
### ä¿å­˜ç³»ç»Ÿå¤–è§‚
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| logoBlobId | Long | å¦ | ä¼ä¸š Logo æ–‡ä»¶ç¼–号;传 null è¡¨ç¤ºæ¸…除 |
| loginBackgroundBlobId | Long | å¦ | ç™»å½•背景图文件编号;传 null è¡¨ç¤ºæ¸…除 |
**请求示例:**
```json
{
  "logoBlobId": 1024,
  "loginBackgroundBlobId": 1025
}
```
**响应:** `{ "code": 0, "data": true }`
## æŽ¥å£è°ƒç”¨æ—¶åº
1. è¿›å…¥ã€Œå¤–观设置」页 â†’ è°ƒç”¨ `GET /system/appearance/get`,回显已配置的图片与 `blobId`。
2. é€‰æ‹©å›¾ç‰‡ â†’ è°ƒç”¨ `POST /system/storage-blob/public-upload` â†’ è®°å½•返回的 `blobId` ä¸Ž `previewURL`,页面立即预览。
3. ç‚¹å‡»ä¿å­˜ â†’ è°ƒç”¨ `POST /system/appearance/save`,提交两个 `blobId`。
4. ä¿å­˜æˆåŠŸåŽ â†’ ç«‹å³æ›´æ–°é¦–页 Logo ä¸Žç™»å½•页背景/Logo,无需刷新。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| loginBackgroundUrl | å¤–观设置页预览、登录页整页背景 | ç™»å½•页背景图,未配置时使用系统默认背景 |
| logoUrl | å¤–观设置页预览、登录页品牌区 Logo、首页侧边栏 Logo | ä¸€å¥—配置同时作用于登录页与首页,未配置时使用系统默认 Logo |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| ä¸Šä¼  | ä»…接受常规图片格式(image/*),使用公共上传接口,返回永久可访问地址 |
| ä¿å­˜ | ä¸¤ç±»å›¾ç‰‡ç‹¬ç«‹ä¿å­˜ï¼Œæ›¿æ¢å…¶ä¸­ä¸€å¼ ä¸å½±å“å¦ä¸€å¼  |
| æ¸…除 | å¯¹åº” `blobId` ä¼  null,保存后恢复系统默认样式 |
| æœªç™»å½•访问 | ç™»å½•页通过 `@PermitAll` çš„外观查询接口读取配置,背景图/Logo ä½¿ç”¨ `?publicKey=` æ°¸ä¹…地址,无需登录 |
| åŠ è½½å¤±è´¥ | å‰ç«¯å¯åŠ¨é˜¶æ®µæŸ¥è¯¢å¤–è§‚å¤±è´¥æ—¶é™é»˜ä¿ç•™é»˜è®¤æ ·å¼ï¼Œä¸é˜»å¡žåº”ç”¨å¯åŠ¨ |
## æ³¨æ„äº‹é¡¹
- å›¾ç‰‡ä¸Šä¼ ä¸Žä¿å­˜æ˜¯ä¸¤æ­¥æ“ä½œï¼Œä¸Šä¼ æˆåŠŸä½†æœªç‚¹å‡»ä¿å­˜ä¸ä¼šç”Ÿæ•ˆã€‚
- å¤–观配置为全系统级(`recordId = 1`),不区分租户与用户。
- å‰ç«¯å¯åŠ¨é˜¶æ®µæŸ¥è¯¢å¤–è§‚æŽ¥å£éœ€å¸¦ `tenant-id` è¯·æ±‚头。
- èœå•「外观设置」由 SQL è„šæœ¬åˆå§‹åŒ–ï¼Œè¶…çº§ç®¡ç†å‘˜é»˜è®¤æ‹¥æœ‰æƒé™ï¼Œå…¶ä»–è§’è‰²éœ€åœ¨è§’è‰²èœå•ä¸­å‹¾é€‰ã€Œä¿å­˜å¤–è§‚è®¾ç½®ã€æŒ‰é’®æƒé™ã€‚
- æ›´æ¢å›¾ç‰‡åŽå»ºè®®ä½¿ç”¨å›ºå®šå°ºå¯¸çš„æ¨ªç‰ˆèƒŒæ™¯å›¾ï¼ˆå¦‚ 1920×1080),Logo å»ºè®®ä½¿ç”¨é€æ˜ŽèƒŒæ™¯ PNG。
docs/warehouse_location_area_removal.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
# ä»“库移除库区/库位 - å‰ç«¯è”调方案
## èƒŒæ™¯
仓库管理改为「仓库 â†’ åº“存」两级结构,不再维护库区(`warehouse_location`)和库位(`warehouse_area`)。
前端已删除库区、库位管理页面及所有业务表单中的库区/库位选择项,后端同步移除对应管理接口,并放宽库区/库位的强校验:未传 `locationId`、`areaId` æ—¶ï¼Œä¸šåŠ¡æŒ‰ä»“åº“ç»´åº¦æ­£å¸¸æ‰§è¡Œã€‚
> æ³¨æ„ï¼šä»£ç ä¸­ `warehouse_location` å¯¹åº”中文「库区」,`warehouse_area` å¯¹åº”中文「库位」,与旧命名字面含义相反,联调时请以字段为准。
## æ¶‰åŠé¡µé¢
- ä»“储物流 - ä»“库设置
- ä»“储物流 - åº“区列表、库位列表(已删除)
- ä»“储物流 - å…¥åº“/出库/移库/盘点/杂项收发货/委外等单据明细
- ä»“储物流 - åº“存查询、库存移库、库存调整
- MES - å·¥ä½œç«™ç»´æŠ¤
- ç§»åŠ¨ç«¯ - æ‰«ç å…¥åº“、库存查询、移库、盘点、销售出库、杂项领料、退货领料
## ç§»é™¤çš„æŽ¥å£
以下接口(库区、库位管理)已从后端删除,调用将返回 404:
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/wm/warehouse-location/page` | åº“区分页 |
| GET | `/mes/wm/warehouse-location/simple-list` | åº“区精简列表 |
| GET | `/mes/wm/warehouse-location/get` | åº“区详情 |
| POST | `/mes/wm/warehouse-location/create` | æ–°å¢žåº“区 |
| PUT | `/mes/wm/warehouse-location/update` | ä¿®æ”¹åº“区 |
| DELETE | `/mes/wm/warehouse-location/delete` | åˆ é™¤åº“区 |
| GET | `/mes/wm/warehouse-area/page` | åº“位分页 |
| GET | `/mes/wm/warehouse-area/simple-list` | åº“位精简列表 |
| GET | `/mes/wm/warehouse-area/get` | åº“位详情 |
| POST | `/mes/wm/warehouse-area/create` | æ–°å¢žåº“位 |
| PUT | `/mes/wm/warehouse-area/update` | ä¿®æ”¹åº“位 |
| DELETE | `/mes/wm/warehouse-area/delete` | åˆ é™¤åº“位 |
## ä¿ç•™å­—段说明
以下接口的 `locationId`、`areaId`、`locationName`、`areaName` å­—段**保留但不再作为必填项**:
| æ¶‰åŠæŽ¥å£ | å­—段 | è§„则 |
|----------|------|------|
| åº“存分页/详情 | `warehouseId`、`locationId`、`areaId` | æŸ¥è¯¢æ¡ä»¶å¯ä¸ä¼ ï¼›è¿”回值为历史数据时可能非空 |
| å„类入库/出库明细保存 | `warehouseId`、`locationId`、`areaId` | ä»… `warehouseId` å¿…å¡«ï¼›`locationId`、`areaId` å¯ä¸ä¼  |
| ç§»åº“单明细保存 | `fromWarehouseId`、`fromLocationId`、`fromAreaId` | ä»… `fromWarehouseId` å¿…å¡« |
| ç§»åº“单头 | `toWarehouseId`、`toLocationId`、`toAreaId` | ä»… `toWarehouseId` å¿…å¡« |
| å·¥ä½œç«™ä¿å­˜ | `warehouseId`、`locationId`、`areaId` | ä»… `warehouseId` å‚与校验;未传时自动落到虚拟线边仓 |
| åº“存移库/库存调整 | `toWarehouseId`、`toLocationId`、`toAreaId` | ä»… `toWarehouseId` å¿…填;目标仓库与当前仓库相同时禁止移库 |
## ä¸šåŠ¡è§„åˆ™å˜åŒ–
| åœºæ™¯ | è°ƒæ•´å‰ | è°ƒæ•´åŽ |
|------|--------|--------|
| ä»“库删除 | ä»“库下存在库区时禁止删除 | ä¸å†æ ¡éªŒåº“区,存在工作站或库存时仍禁止删除 |
| åº“存冻结校验 | åº“区/库位不存在时报错 | `locationId`、`areaId` ä¸ºç©ºæ—¶è·³è¿‡åº“区/库位冻结校验 |
| åº“存混放校验 | åº“位不存在时报错 | æœªæŒ‡å®šåº“位时跳过物料/批次混放校验 |
| åº“存选择校验 | å‰ç«¯ä¼ å…¥çš„库区/库位必须与库存记录完全一致 | ä»…在传入非空 `locationId`、`areaId` æ—¶æ¯”对 |
| å·¥ä½œç«™ä¿å­˜ | åº“区必须属于仓库、库位必须属于库区 | ä»…校验仓库存在,库区/库位不再参与 |
| ç§å­è´¨æ£€åˆæ ¼å…¥åº“ | åˆæ ¼åº“未配置库区/库位时报错 | åº“区/库位取合格库下首个非冻结库区/库位,未配置时按仓库直接入库 |
| åº“存合并(移库/合格入库) | ç»„合键中库区/库位采用精确匹配 | `locationId`、`areaId` ä¸ºç©ºæ—¶å¿½ç•¥è¯¥ç»´åº¦ï¼ŒæŒ‰ç‰©æ–™+仓库+批次匹配 |
## æ•°æ®å¸¦å…¥è¯´æ˜Ž
1. å•据明细选择库存记录后,页面只带入 `itemId`、`batchId`、`batchCode`、`quantity`、`warehouseId`,不再带入 `itemName`/`batchCode` ä¹‹å¤–的库区、库位名称。
2. ç§»åº“、库存调整的目标位置只选择仓库,提交时只传目标仓库编号。
3. ç§»åŠ¨ç«¯æ‰«ç å‡ºå…¥åº“æŒ‰è¿½æº¯ç è§£æžç‰©æ–™ä¸Žæ‰¹æ¬¡ï¼Œè½åº“ä½ç½®é»˜è®¤ä¸ºç”Ÿäº§æš‚å­˜ä»“/合格库,不再要求选择库区、库位。
## æ³¨æ„äº‹é¡¹
- åŽ†å²æ•°æ®ä¸­ `location_id`、`area_id` å¯èƒ½ä»ä¸ºéžç©ºå€¼ï¼Œå±•示层可忽略,不影响查询与出入库。
- å‰ç«¯è°ƒç”¨å‡ºå…¥åº“、移库、盘点等保存接口时,请不要再传库区/库位;若仍传入历史 ID,后端会照常校验其存在性,ID å¤±æ•ˆå°†å¯¼è‡´æŠ¥é”™ã€‚
- å·²æ‰§è¡Œ `sql/mysql/mes_wm_remove_warehouse_location_area_menu.sql` æ¸…理库区/库位菜单与权限后,前端不能再通过菜单进入旧页面。
- åº“存组合键(物料+仓库+库区+库位+批次+供应商)中库区/库位变为可选;同一仓库同批次不同库区的历史库存可能在移库合并时被合并到一条记录。
docs/ÏúÊÛ³ö¿â·Ö´Î·¢»õ˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
# é”€å”®å‡ºåº“优化修改说明
## 1. ä¿®æ”¹èŒƒå›´å’Œå½“前状态
本次修改覆盖销售出库分次发货、PC å’Œç§»åŠ¨ç«¯æ‹£è´§ã€åº“å­˜è¢‹ç çŠ¶æ€ã€åº“å­˜äº‹åŠ¡æµæ°´åŠæ‰¹æ¬¡è¿½æº¯ã€‚ä»£ç å·²ç»ä¿®æ”¹ï¼Œ**本次整套修改尚未执行编译、自动化测试或页面联调**;以下描述是实现意图和待验证行为,不代表验收已通过。
历史版本中曾对部分后端代码执行编译,但不能作为本次全部修改的验证结果。测试或部署前,应先核对当前工作区的实际代码和数据库结构。
## 2. åŠŸèƒ½ä¿®æ”¹
### 2.1 é”€å”®å‡ºåº“与分次发货
- ä¸€å¼ é”€å”®å‡ºåº“单可创建多次发货记录。每次先拣货、填写运单,再执行本次出库。
- æœ¬æ¬¡è‡³å°‘选择一条待发明细;各行累计拣货数量不得超过该行计划数量。允许只发部分物料行。
- æœ¬æ¬¡å‡ºåº“只处理该发货记录的明细并生成对应库存流水;发货通知单按本次实际明细数量累计回写。
- å…¨éƒ¨è®¡åˆ’数量出库后单据完成;未全部发完时,单据返回待拣货,可继续下一次发货。已部分发货的单据在页面上显示相应提示。
- å¾…运单或待出库时可以取消本次发货,释放占用,明细保留并返回待拣货状态。已有发货但不再继续发货时,可先移除本轮未发明细,填写原因关闭剩余数量。
- å·²åˆ†é…å‘货记录的明细不能直接修改或删除;已出库后的纠正应走退货等业务流程。
### 2.2 æ‰«ç åŠæ‰‹å·¥æ‹£è´§
- æ”¯æŒæ‰«ææˆ–输入批次码、托盘码、袋码,识别后按实际袋码选择。例如“1 æ‰¹ + 4 æ‰˜ç›˜ + 8 è¢‹â€å¯ä»¥åˆ†æ¬¡è¯†åˆ«åŽåŠ å…¥åŒä¸€æ¬¡æ‹£è´§æ¸…å•ã€‚
- è¢‹ç æŒ‰å®žé™…选中结果生成明细;重复、已出库、未质检合格、冻结或不在有效合格库存的袋码应被拦截或标明不可选原因。
- PC ç«¯æ‰‹å·¥æ‹£è´§å¯å…ˆé€‰æ‹©åº“存,再加载并选择该库存的袋码;移动端可从出库单行选择库存袋码,也支持手动输入码值。
- æœ‰åœ¨åº“袋码明细的库存不能直接创建无袋码的数量拣货明细;无在库袋码明细的历史库存保留按数量拣货入口,但无法逐袋追溯。
- æ˜Žç»†ä¸­çš„æ‰«ç ç±»åž‹åŠæ¥æºç æ˜¾ç¤ºä¸ºä¸­æ–‡ï¼Œä¾‹å¦‚“扫码类型 æ‰¹æ¬¡ç ï¼›æ¥æºæ‰¹æ¬¡ç  â€¦â€ï¼›â€œè¢‹ç ä¸Žæ‰«ç æ¥æºâ€åˆ—支持鼠标悬停查看。
### 2.3 åº“存、流水与追溯
- åº“存列表默认排除零库存;PC å’Œç§»åŠ¨ç«¯å¯é€‰æ‹©åŒ…å«é›¶åº“å­˜è®°å½•å¹¶æŸ¥çœ‹ä»å…³è”è¯¥åº“å­˜è®°å½•çš„è¢‹ç åŽ†å²ã€‚
- è¢‹ç æ˜Žç»†æ˜¾ç¤ºåœ¨åº“、已拣选、已占用、已出库或已报废等状态;只有关联流水可判断为销售出库时才显示“销售已出库”。
- åº“存事务流水增加托盘码、袋码及发货记录关联。历史流水没有关联数据时显示未记录,不推测具体袋码。
- æ‰¹æ¬¡è¿½æº¯ä¸­çš„销售事件按实际销售出库流水聚合;可展开查询相应出库单、批次及发货记录的流水。
## 3. æ•°æ®åº“及部署
1. å¤‡ä»½ç›®æ ‡æ•°æ®åº“,确认已有库存袋码、销售出库追溯等基础表和字段已按原版本部署。
2. æ ¸å¯¹ç›®æ ‡åº“后,执行一次 `sql/mysql/mes_wm_product_sales_shipment.sql`:新增 `mes_wm_product_sales_shipment` è¡¨ï¼Œåœ¨é”€å”®å‡ºåº“单增加 `close_reason`,在出库明细及库存事务流水增加 `shipment_id` å’Œç´¢å¼•。
3. è¯¥ SQL **不可重复执行**;先检查目标库是否已经包含表和字段,避免重复执行。不要把它与其他未确认用途的 SQL ä¸€å¹¶æ‰§è¡Œã€‚
4. åè°ƒå‘布后端及 PC、移动端前端,确保代码和数据库结构版本一致;在测试环境完成下列验证后再安排正式环境发布。
### åŽ†å²æ•°æ®å…¼å®¹
- åŽ†å²å·²å®Œæˆå•æ®çš„æ˜Žç»†åŠæµæ°´ä¸ä¼šè‡ªåŠ¨ç”Ÿæˆå‘è´§è®°å½•ï¼›åŽ†å²æµæ°´ä»ä»¥åŽŸæœ‰æ•°æ®ä¸ºå‡†ã€‚
- å‡çº§æ—¶å·²å¤„于待填写运单或待执行出库的旧单据,继续操作时会为已有明细建立发货记录,需重点验证库存占用和通知单回写。
- æ— æ³•从仅有数量的历史明细可靠还原实际袋码,不自动补造袋码。
- å½“前袋码明细记录的是袋码当前关联的库存记录及状态,不应将其当作所有历史移库位置的完整快照;历史流转以事务流水为准。
## 4. ä¸»è¦æŽ¥å£å’Œæƒé™
| æŽ¥å£ | ç”¨é€” | æƒé™ |
| --- | --- | --- |
| `GET /mes/wm/product-sales/shipments` | æŸ¥çœ‹å‘货记录 | `mes:wm-product-sales:query` |
| `GET /mes/wm/product-sales/scan-preview` | è¯†åˆ«æ‰¹æ¬¡ã€æ‰˜ç›˜æˆ–袋码,返回袋码可选状态 | `mes:wm-product-sales:stock` |
| `POST /mes/wm/product-sales/scan` | å°†é€‰ä¸­çš„袋码加入拣货;手工选袋可指定行和库存 | `mes:wm-product-sales:stock` |
| `PUT /mes/wm/product-sales/stock` | å®Œæˆæœ¬æ¬¡æ‹£è´§å¹¶å ç”¨åº“å­˜ | `mes:wm-product-sales:stock` |
| `PUT /mes/wm/product-sales/shipping` | å¡«å†™æœ¬æ¬¡è¿å• | `mes:wm-product-sales:shipping` |
| `PUT /mes/wm/product-sales/finish` | æ‰§è¡Œæœ¬æ¬¡å‡ºåº“ | `mes:wm-product-sales:finish` |
| `PUT /mes/wm/product-sales/cancel-shipment` | å–消尚未出库的本次发货并释放占用 | `mes:wm-product-sales:cancel` |
| `PUT /mes/wm/product-sales/close-remaining` | å¡«å†™åŽŸå› å¹¶å…³é—­å‰©ä½™æ•°é‡ | `mes:wm-product-sales:cancel` |
| `GET /mes/wm/material-stock/picking-list` | æ ¹æ®å‡ºåº“单行查询合格库存 | åŒæ—¶éœ€è¦é”€å”®æ‹£è´§å’Œåº“存查询权限 |
| `POST /mes/wm/product-sales-detail/create-mobile-manual` | ç§»åŠ¨ç«¯æŒ‰æ•°é‡æ‹£é€‰æ— è¢‹ç åŽ†å²åº“å­˜ | `mes:wm-product-sales:stock` |
| `GET /mes/wm/material-stock/bag-details` | æŸ¥è¯¢åº“存袋码;`includeHistory=true` åŒ…含历史状态 | `mes:wm-material-stock:query` |
| `GET /mes/wm/transaction/page` | æŸ¥è¯¢åº“存流水;支持精确单号、批次和发货记录筛选 | `mes:wm-transaction:query` |
## 5. æ•´ä½“测试清单
下列项目均为**待验证**:
1. **分次发货**:同一张单据分两次发货;逐次核对库存、发货记录、运单、各行已出库及剩余量、通知单累计量和状态。覆盖仅发部分物料行。
2. **混合扫码**:按批次、托盘、单袋混合选择;核对实际袋码、来源类型与来源码。覆盖重复扫描、交叉范围、跨单并发、冻结、未质检合格和超出剩余数量。
3. **手工拣货**:PC å’Œç§»åŠ¨ç«¯åˆ†åˆ«é€‰æ‹©å®žé™…è¢‹ç ï¼›å¯¹æ— è¢‹ç åŽ†å²åº“å­˜éªŒè¯æ•°é‡æ‹£è´§åŠæ— è¢‹ç æç¤ºï¼›éªŒè¯å·²æœ‰è¢‹ç åº“å­˜ä¸èƒ½ç»•è¿‡é€è¢‹é€‰æ‹©ã€‚
4. **取消与关闭**:取消待出库发货后核对占用释放及明细调整;已有出库后关闭剩余量,核对关闭原因及通知单不会误标全部出库。
5. **追溯和历史**:核对零库存袋码历史、库存流水的批次/托盘/袋码、批次时间线的每次出库。覆盖历史已完成单据和升级前待运单/待出库单据。
6. **异常与权限**:覆盖请求超时后的移动端结果核实、重复提交、不同用户并发拣选、库存不足以及缺少相关菜单权限的情况。
关键核对关系:同一发货记录内,本次实际明细数量、库存扣减数量、销售出库流水数量应一致;通知单累计出库量应等于各次实际回写量,不能超过计划量。不同物料或不同单位应分别核对。
scripts/deploy-seed-quality-bpm.ps1
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
param(
    [Parameter(Mandatory = $true)][string]$ApiBaseUrl,
    [Parameter(Mandatory = $true)][string]$AccessToken,
    [long]$AdminUserId = 1,
    # æµç¨‹åˆ†ç±»ç¼–码,需与 BPM「流程分类」中的 code ä¸€è‡´ï¼›ä¸ºç©ºåˆ™æ¨¡åž‹ä¸ä¼šæ˜¾ç¤ºåœ¨æµç¨‹æ¨¡åž‹é¡µ
    [string]$CategoryCode = 'mes_qc_approve'
)
$ErrorActionPreference = 'Stop'
$base = $ApiBaseUrl.TrimEnd('/')
$headers = @{ Authorization = "Bearer $AccessToken" }
$resourceRoot = Join-Path $PSScriptRoot '../yudao-module-mes/src/main/resources/bpm'
# ä¸­æ–‡å‘½åç»Ÿä¸€ä»Ž BPMN æ–‡ä»¶è¯»å–,避免 Windows PowerShell 5.1 æŒ‰ ANSI è§£æžè„šæœ¬å¯¼è‡´ä¹±ç 
$models = @(
    'mes_seed_quality_line_approve',
    'mes_seed_quality_company_approve',
    'mes_seed_return_approve'
)
function Invoke-BpmApi([string]$Method, [string]$Path, $Body) {
    $request = @{ Method = $Method; Uri = "$base$Path"; Headers = $headers }
    if ($null -ne $Body) {
        $json = ConvertTo-Json -InputObject $Body -Depth 12 -Compress
        # Windows PowerShell 5.1 é»˜è®¤æŒ‰ ISO-8859-1 ç¼–码请求体,必须显式转 UTF-8 å­—节
        $request.ContentType = 'application/json; charset=utf-8'
        $request.Body = [System.Text.Encoding]::UTF8.GetBytes($json)
    }
    $result = Invoke-RestMethod @request
    if ($result.code -ne 0) { throw "BPM API $Path failed: $($result.msg)" }
    return $result.data
}
$existing = @(Invoke-BpmApi 'GET' '/bpm/model/list' $null)
foreach ($key in $models) {
    $path = Join-Path $resourceRoot "$key.bpmn20.xml"
    $xml = Get-Content -LiteralPath $path -Raw -Encoding UTF8
    if (-not $xml.Contains('flowable:candidateParam="1"')) {
        throw "BPM model $key has no admin candidate placeholder"
    }
    $xml = $xml -replace 'flowable:candidateParam="1"', "flowable:candidateParam=`"$AdminUserId`""
    [xml]$doc = $xml
    $processName = [string]$doc.definitions.process.name
    $model = $existing | Where-Object { $_.key -eq $key } | Select-Object -First 1
    $body = @{
        key = $key; name = $processName; category = $CategoryCode; type = 10; formType = 20
        formCustomCreatePath = '/mes/qc/seed-quality-record'
        formCustomViewPath = '/mes/qc/seed-quality-record'
        visible = $false; startUserIds = @(); managerUserIds = @($AdminUserId)
        autoApprovalType = 0; bpmnXml = $xml
    }
    if ($null -eq $model) {
        $id = Invoke-BpmApi 'POST' '/bpm/model/create' $body
    } else {
        $id = $model.id
        $body.id = $id
        [void](Invoke-BpmApi 'PUT' '/bpm/model/update' $body)
    }
    [void](Invoke-BpmApi 'POST' "/bpm/model/deploy?id=$([uri]::EscapeDataString([string]$id))" $null)
    Write-Output "Deployed $key ($id)"
}
seed-range-redesign.png
sql/mysql/crm_customer_dealer_fields_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
-- =============================================
-- CRM å®¢æˆ·æ–°å¢žç»é”€å•†å¤‡æ¡ˆç›¸å…³å­—段
-- ç›®æ ‡åº“:mom-thzy
-- è¯´æ˜Žï¼šMySQL çš„ ALTER TABLE ADD COLUMN ä¸æ”¯æŒ IF NOT EXISTS,
--       å…ˆæŸ¥ information_schema å†æ‰§è¡Œï¼Œä¿è¯è„šæœ¬å¯é‡å¤æ‰§è¡Œã€‚
-- =============================================
SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE()
      AND table_name = 'crm_customer'
      AND column_name = 'social_credit_code'
);
SET @ddl = IF(@col_exists = 0,
    'ALTER TABLE `crm_customer`
        ADD COLUMN `social_credit_code` VARCHAR(64) NULL COMMENT ''社会信用代码'' AFTER `name`,
        ADD COLUMN `id_card_no` VARCHAR(32) NULL COMMENT ''身份证号'' AFTER `social_credit_code`,
        ADD COLUMN `customer_name` VARCHAR(64) NULL COMMENT ''客户姓名'' AFTER `id_card_no`,
        ADD COLUMN `filing_serial_no` VARCHAR(64) NULL COMMENT ''备案流水号'' AFTER `customer_name`,
        ADD COLUMN `bank_account_name` VARCHAR(128) NULL COMMENT ''银行账户名称'' AFTER `filing_serial_no`,
        ADD COLUMN `region` VARCHAR(64) NULL COMMENT ''所属区域'' AFTER `bank_account_name`,
        ADD COLUMN `parent_dealer_id` BIGINT NULL COMMENT ''所属上级经销商编号'' AFTER `region`',
    'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ç´¢å¼•同样用 information_schema åˆ¤æ–­ï¼ˆMySQL 8.4 çš„ ADD INDEX IF NOT EXISTS ä¸å¯ç”¨ï¼‰
SET @idx_exists = (
    SELECT COUNT(*) FROM information_schema.statistics
    WHERE table_schema = DATABASE() AND table_name = 'crm_customer'
      AND index_name = 'idx_crm_customer_parent_dealer_id'
);
SET @ddl = IF(@idx_exists = 0,
    'ALTER TABLE `crm_customer` ADD INDEX `idx_crm_customer_parent_dealer_id` (`parent_dealer_id`)',
    'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx_exists = (
    SELECT COUNT(*) FROM information_schema.statistics
    WHERE table_schema = DATABASE() AND table_name = 'crm_customer'
      AND index_name = 'idx_crm_customer_social_credit_code'
);
SET @ddl = IF(@idx_exists = 0,
    'ALTER TABLE `crm_customer` ADD INDEX `idx_crm_customer_social_credit_code` (`social_credit_code`)',
    'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
sql/mysql/mes_qc_approver_menu_retire_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
-- BPM è´¨æ£€æµç¨‹å¯ç”¨åŽæ‰§è¡Œã€‚仅隐藏旧审核人配置菜单和按钮,保留配置记录以供历史追溯。
-- æ‰§è¡Œå‰ç¡®è®¤ä¸‰ä¸ª BPM æµç¨‹å®šä¹‰å·²å‘布,并已处理旧的在途人工审批记录。
UPDATE system_menu child
JOIN system_menu parent ON child.parent_id = parent.id
SET child.status = 1, child.visible = b'0', child.update_time = NOW()
WHERE parent.parent_id = 5500 AND parent.path = 'approver-config'
  AND parent.deleted = b'0' AND child.deleted = b'0';
UPDATE system_menu
SET status = 1, visible = b'0', update_time = NOW()
WHERE parent_id = 5500 AND path = 'approver-config' AND deleted = b'0';
sql/mysql/mes_qc_seed_indicator_range_product_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
-- è´¨æ£€é¡¹åˆæ ¼èŒƒå›´é…ç½®ï¼šç”±å…¨å±€å›ºå®šå››é¡¹æ”¹ä¸ºæŒ‰äº§å“ç»´æŠ¤
-- 1. ä¸»é”®ç”± indicator_code æ”¹ä¸ºè‡ªå¢ž id
-- 2. æ–°å¢žäº§å“ç»‘定、名称、单位、排序、状态字段
-- 3. åŽ†å²å››é¡¹è‡ªåŠ¨è¿ç§»ä¸ºé€šç”¨é»˜è®¤é¡¹ï¼ˆitem_id ä¸ºç©ºï¼‰ï¼Œäº§å“æœªé…ç½®æ—¶å›žé€€ä½¿ç”¨
-- 4. è´¨æ£€æŒ‡æ ‡è®°å½•补充名称与单位快照
-- ===== 1. ä¸»é”®æ”¹ä¸ºè‡ªå¢ž id =====
SET @has_id = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_indicator_range' AND column_name = 'id');
SET @sql = IF(@has_id = 0,
  'ALTER TABLE mes_qc_seed_indicator_range DROP PRIMARY KEY, ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT FIRST, ADD PRIMARY KEY (id)',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ===== 2. æ–°å¢žé…ç½®å­—段(MySQL ä¸æ”¯æŒ ADD COLUMN IF NOT EXISTS,逐列判断)=====
SET @has_item_id = (SELECT COUNT(*) FROM information_schema.columns
                    WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_indicator_range' AND column_name = 'item_id');
SET @sql = IF(@has_item_id = 0,
  'ALTER TABLE mes_qc_seed_indicator_range ADD COLUMN item_id BIGINT NULL COMMENT ''绑定产品编号(mdm_item,itemType=2),为空表示通用默认项'' AFTER id',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_name = (SELECT COUNT(*) FROM information_schema.columns
                 WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_indicator_range' AND column_name = 'indicator_name');
SET @sql = IF(@has_name = 0,
  'ALTER TABLE mes_qc_seed_indicator_range ADD COLUMN indicator_name VARCHAR(64) NULL COMMENT ''质检项名称'' AFTER indicator_code',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_unit = (SELECT COUNT(*) FROM information_schema.columns
                 WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_indicator_range' AND column_name = 'unit');
SET @sql = IF(@has_unit = 0,
  'ALTER TABLE mes_qc_seed_indicator_range ADD COLUMN unit VARCHAR(16) NULL COMMENT ''计量单位'' AFTER indicator_name',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_sort = (SELECT COUNT(*) FROM information_schema.columns
                 WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_indicator_range' AND column_name = 'sort');
SET @sql = IF(@has_sort = 0,
  'ALTER TABLE mes_qc_seed_indicator_range ADD COLUMN sort INT NOT NULL DEFAULT 0 COMMENT ''排序'' AFTER max_value',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_status = (SELECT COUNT(*) FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_indicator_range' AND column_name = 'status');
SET @sql = IF(@has_status = 0,
  'ALTER TABLE mes_qc_seed_indicator_range ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT ''状态:0启用 1停用'' AFTER sort',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ===== 3. åŽ†å²æ•°æ®å›žå¡« =====
UPDATE mes_qc_seed_indicator_range
SET indicator_name = CASE indicator_code
    WHEN 'purity' THEN '纯度'
    WHEN 'cleanliness' THEN '净度'
    WHEN 'germination_rate' THEN '发芽率'
    WHEN 'moisture' THEN '水分'
    ELSE indicator_code END
WHERE indicator_name IS NULL OR indicator_name = '';
UPDATE mes_qc_seed_indicator_range SET unit = '%' WHERE unit IS NULL OR unit = '';
UPDATE mes_qc_seed_indicator_range
SET sort = CASE indicator_code
    WHEN 'purity' THEN 10
    WHEN 'cleanliness' THEN 20
    WHEN 'germination_rate' THEN 30
    WHEN 'moisture' THEN 40
    ELSE 999 END
WHERE sort IS NULL OR sort = 0;
-- ===== 4. äº§å“ + ç¼–码索引 =====
SET @has_index = (SELECT COUNT(*) FROM information_schema.statistics
                  WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_indicator_range' AND index_name = 'idx_item_code');
SET @sql = IF(@has_index = 0,
  'ALTER TABLE mes_qc_seed_indicator_range ADD INDEX idx_item_code (item_id, indicator_code)',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ===== 5. è´¨æ£€æŒ‡æ ‡è®°å½•补充名称与单位快照 =====
SET @has_ind_name = (SELECT COUNT(*) FROM information_schema.columns
                     WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_quality_indicator' AND column_name = 'indicator_name');
SET @sql = IF(@has_ind_name = 0,
  'ALTER TABLE mes_qc_seed_quality_indicator ADD COLUMN indicator_name VARCHAR(64) NULL COMMENT ''指标名称快照'' AFTER indicator_code',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_ind_unit = (SELECT COUNT(*) FROM information_schema.columns
                     WHERE table_schema = DATABASE() AND table_name = 'mes_qc_seed_quality_indicator' AND column_name = 'unit');
SET @sql = IF(@has_ind_unit = 0,
  'ALTER TABLE mes_qc_seed_quality_indicator ADD COLUMN unit VARCHAR(16) NULL COMMENT ''指标单位快照'' AFTER indicator_name',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE mes_qc_seed_quality_indicator
SET indicator_name = CASE indicator_code
    WHEN 'purity' THEN '纯度'
    WHEN 'cleanliness' THEN '净度'
    WHEN 'germination_rate' THEN '发芽率'
    WHEN 'moisture' THEN '水分'
    ELSE indicator_code END
WHERE indicator_name IS NULL OR indicator_name = '';
UPDATE mes_qc_seed_quality_indicator SET unit = '%' WHERE unit IS NULL OR unit = '';
sql/mysql/mes_seed_quality_dual_line_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,142 @@
-- =====================================================================
-- ç§å­è´¨æ£€ã€ŒåŠ å·¥éƒ¨ / é”€å”®éƒ¨ã€åŒçº¿æ£€éªŒ + BPM å®¡æ‰¹ + ç»é”€å•†é€€è´§å¤æ£€
--
-- å˜æ›´å†…容:
--   1. ä¸€æ¡è¢‹ç çš„初检同时生成加工部、销售部两条质检记录(qc_line)
--   2. ä¸¤æ¡çº¿å„自走部门领导审批,互不影响
--   3. ä¸¤æ¡çº¿éƒ½è¿›å…¥å¤æ£€å®¡æ ¸åŽï¼Œç³»ç»Ÿè‡ªåŠ¨å‘èµ·å…¬å¸è´¨æ£€å®¡æ‰¹
--   4. ç»é”€å•†é€€è´§å¤æ£€å¤ç”¨åŒä¸€å¼ è¡¨ï¼ˆbiz_type=2),单独走销售部线
--
-- æœ¬è„šæœ¬å…¨éƒ¨ DDL å¸¦å­˜åœ¨æ€§åˆ¤æ–­ï¼Œå¯å¯¹åŒä¸€çŽ¯å¢ƒé‡å¤æ‰§è¡Œã€‚
-- ä¸ä½¿ç”¨å¤šç§Ÿæˆ·ï¼Œæ—  tenant_id å­—段。
-- æ‰§è¡Œå‰è¯·å…ˆ use <目标库>。
-- =====================================================================
SET @db = DATABASE();
-- ---------------------------------------------------------------------
-- 1. è´¨æ£€çº¿ï¼š1 åŠ å·¥éƒ¨ 2 é”€å”®éƒ¨ 0 æœªåŒºåˆ†ï¼ˆåŽ†å²æ•°æ®ï¼‰
-- ---------------------------------------------------------------------
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'qc_line');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `qc_line` TINYINT NOT NULL DEFAULT 0 COMMENT ''质检线:1加工部 2销售部 0未区分(历史数据)'' AFTER `code`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------
-- 2. ä¸šåŠ¡ç±»åž‹ï¼š1 ç”Ÿäº§å…¥åº“质检 2 ç»é”€å•†é€€è´§è´¨æ£€
-- ---------------------------------------------------------------------
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'biz_type');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `biz_type` TINYINT NOT NULL DEFAULT 1 COMMENT ''业务类型:1生产入库质检 2经销商退货质检'' AFTER `qc_line`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------
-- 3. BPM æµç¨‹å®žä¾‹ä¸Žæ—¶é—´èŠ‚ç‚¹
-- ---------------------------------------------------------------------
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'process_instance_id');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `process_instance_id` VARCHAR(64) DEFAULT NULL COMMENT ''本线部门审批流程实例编号'' AFTER `workflow_status`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'company_process_instance_id');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `company_process_instance_id` VARCHAR(64) DEFAULT NULL COMMENT ''公司质检审批流程实例编号'' AFTER `process_instance_id`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'line_finished_at');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `line_finished_at` DATETIME DEFAULT NULL COMMENT ''本线部门审批完成时间'' AFTER `process_approved_at`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'company_submitted_at');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `company_submitted_at` DATETIME DEFAULT NULL COMMENT ''公司质检审批发起时间'' AFTER `line_finished_at`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------
-- 4. ç»é”€å•†é€€è´§å…³è”
-- ---------------------------------------------------------------------
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'return_sales_id');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `return_sales_id` BIGINT DEFAULT NULL COMMENT ''经销商退货单编号'' AFTER `qualified_warehouse_id`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'return_sales_code');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD COLUMN `return_sales_code` VARCHAR(64) DEFAULT NULL COMMENT ''经销商退货单号'' AFTER `return_sales_id`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------
-- 5. å”¯ä¸€é”®ç”± (code) è°ƒæ•´ä¸º (code, qc_line),同袋码允许两条质检线记录
--    å…ˆåˆ é™¤æ—§å”¯ä¸€é”®ï¼Œå†åˆ›å»ºæ–°å”¯ä¸€é”®ï¼Œå‡å¸¦å­˜åœ¨æ€§åˆ¤æ–­
-- ---------------------------------------------------------------------
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND index_name = 'uk_code');
SET @sql = IF(@exists > 0,
  'ALTER TABLE `mes_qc_seed_quality` DROP INDEX `uk_code`',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND index_name = 'uk_code_line');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD UNIQUE KEY `uk_code_line` (`code`, `qc_line`)',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------
-- 6. è¾…助索引:流程实例回查、业务类型筛选
-- ---------------------------------------------------------------------
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND index_name = 'idx_process_instance');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD INDEX `idx_process_instance` (`process_instance_id`)',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND index_name = 'idx_company_process_instance');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD INDEX `idx_company_process_instance` (`company_process_instance_id`)',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND index_name = 'idx_biz_type');
SET @sql = IF(@exists = 0,
  'ALTER TABLE `mes_qc_seed_quality` ADD INDEX `idx_biz_type` (`biz_type`, `workflow_status`)',
  'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- åŽ†å²è®°å½•ç»Ÿä¸€å½’ä¸ºç”Ÿäº§å…¥åº“è´¨æ£€ï¼Œè´¨æ£€çº¿ä¿æŒ 0(未区分),避免重复审批
UPDATE `mes_qc_seed_quality` SET `biz_type` = 1 WHERE `biz_type` IS NULL OR `biz_type` = 0;
-- ---------------------------------------------------------------------
-- 7. æ‰§è¡ŒåŽè‡ªæ£€ï¼špresent=1 è¡¨ç¤ºå­—段/索引已就绪
-- ---------------------------------------------------------------------
SELECT 'mes_qc_seed_quality.qc_line' AS item, COUNT(*) AS present FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'qc_line'
UNION ALL SELECT 'mes_qc_seed_quality.biz_type', COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'biz_type'
UNION ALL SELECT 'mes_qc_seed_quality.process_instance_id', COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'process_instance_id'
UNION ALL SELECT 'mes_qc_seed_quality.company_process_instance_id', COUNT(*) FROM information_schema.columns
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND column_name = 'company_process_instance_id'
UNION ALL SELECT 'INDEX uk_code_line', COUNT(*) FROM information_schema.statistics
  WHERE table_schema = @db AND table_name = 'mes_qc_seed_quality' AND index_name = 'uk_code_line';
sql/mysql/mes_wm_product_sales_shipment.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
-- åˆ†æ¬¡å‘货结构:上线前对目标库执行一次;历史已完成单据保留原流水,不自动推断发货批次。
CREATE TABLE mes_wm_product_sales_shipment (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '发货记录编号',
    sales_id BIGINT NOT NULL COMMENT '销售出库单编号',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0拣货中,10待运单,20待出库,30已出库,40已取消',
    carrier VARCHAR(100) DEFAULT NULL COMMENT '承运商',
    shipping_number VARCHAR(100) DEFAULT NULL COMMENT '运单号',
    outbound_time DATETIME DEFAULT NULL COMMENT '实际出库时间',
    outbound_user_id BIGINT DEFAULT NULL COMMENT '出库操作人',
    creator VARCHAR(64) DEFAULT '' COMMENT '创建者',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updater VARCHAR(64) DEFAULT '' COMMENT '更新者',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除',
    PRIMARY KEY (id),
    KEY idx_sales_status (sales_id, status, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售出库分次发货记录';
ALTER TABLE mes_wm_product_sales
    ADD COLUMN close_reason VARCHAR(500) DEFAULT NULL COMMENT '部分发货后关闭剩余数量的原因';
ALTER TABLE mes_wm_product_sales_detail
    ADD COLUMN shipment_id BIGINT DEFAULT NULL COMMENT '发货记录ID;历史明细为空',
    ADD KEY idx_shipment_id (shipment_id);
ALTER TABLE mes_wm_transaction
    ADD COLUMN shipment_id BIGINT DEFAULT NULL COMMENT '销售发货记录ID;历史流水为空',
    ADD KEY idx_shipment_id (shipment_id);
sql/mysql/mes_wm_remove_warehouse_location_area_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
-- =============================================
-- ç§»é™¤ä»“库"库区/库位"菜单与权限
-- ä»“库不再维护库区(warehouse_location)、库位(warehouse_area),
-- åŒæ­¥æ¸…理后台菜单及角色授权,避免前端访问已删除的页面组件。
-- =============================================
-- 1. æ¸…理角色与库区/库位菜单的授权关联
DELETE rm FROM system_role_menu rm
JOIN system_menu m ON m.id = rm.menu_id
WHERE m.component LIKE 'wls/warehouse/location%'
   OR m.component LIKE 'wls/warehouse/area%'
   OR m.component LIKE 'mes/wm/warehouse/location%'
   OR m.component LIKE 'mes/wm/warehouse/area%'
   OR m.permission LIKE 'mes:wm-warehouse-location%'
   OR m.permission LIKE 'mes:wm-warehouse-area%';
-- 2. åˆ é™¤åº“区/库位菜单
DELETE FROM system_menu
WHERE component LIKE 'wls/warehouse/location%'
   OR component LIKE 'wls/warehouse/area%'
   OR component LIKE 'mes/wm/warehouse/location%'
   OR component LIKE 'mes/wm/warehouse/area%'
   OR permission LIKE 'mes:wm-warehouse-location%'
   OR permission LIKE 'mes:wm-warehouse-area%';
sql/mysql/qc_seed_quality_line_menus_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
-- è´¨é‡ç®¡ç†ï¼šæ–°å¢žã€ŒåŠ å·¥éƒ¨è´¨æ£€ã€ã€Œé”€å”®éƒ¨è´¨æ£€ã€èœå•ï¼ˆå¤ç”¨è´¨æ£€è®°å½•é¡µé¢ï¼ŒæŒ‰è´¨æ£€çº¿å›ºå®šè¿‡æ»¤ï¼‰
-- çˆ¶èœå•:5500 è´¨é‡ç®¡ç†
-- åŠ å·¥éƒ¨è´¨æ£€
INSERT INTO `system_menu`
    (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
     `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065303, '加工部质检', '', 2, 96, 5500, 'seed-quality-record-processing', 'ep:document-checked',
       'mes/qc/seedQuality/record-processing', 'MesQcSeedQualityRecordProcessing',
       0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE id = 1065303);
-- é”€å”®éƒ¨è´¨æ£€
INSERT INTO `system_menu`
    (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
     `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065304, '销售部质检', '', 2, 97, 5500, 'seed-quality-record-sales', 'ep:document-checked',
       'mes/qc/seedQuality/record-sales', 'MesQcSeedQualityRecordSales',
       0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE id = 1065304);
sql/mysql/system_appearance_init_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
-- =============================================
-- ç³»ç»Ÿå¤–观设置菜单初始化
-- ç³»ç»Ÿç®¡ç† -> å¤–观设置(登录页背景图、企业 Logo)
-- é¡µé¢è·¯ç”± /system/appearance/index
-- ä¿å­˜æƒé™ system:appearance:update
-- ç›®æ ‡åº“:mom-thzy
-- =============================================
-- é¡µé¢èœå•(挂在「系统管理」下)
INSERT INTO `system_menu`
    (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
     `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065301, '外观设置', '', 2, 15, 1, 'appearance', 'ep:picture', 'system/appearance/index', 'SystemAppearance',
       0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE id = 1065301);
-- ä¿å­˜æŒ‰é’®æƒé™
INSERT INTO `system_menu`
    (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
     `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065302, '保存外观设置', 'system:appearance:update', 3, 1, 1065301, '', '', '', NULL,
       0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
WHERE EXISTS (SELECT 1 FROM `system_menu` WHERE id = 1065301)
  AND NOT EXISTS (SELECT 1 FROM `system_menu` WHERE id = 1065302);
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerController.java
@@ -35,8 +35,10 @@
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
@@ -139,9 +141,16 @@
        Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId));
        // 1.2 èŽ·å–è·ç¦»è¿›å…¥å…¬æµ·çš„æ—¶é—´
        Map<Long, Long> poolDayMap = getPoolDayMap(list);
        // 1.3 èŽ·å–æ‰€å±žä¸Šçº§ç»é”€å•†åç§°
        Set<Long> parentDealerIds = convertSet(list, CrmCustomerDO::getParentDealerId);
        parentDealerIds.remove(null);
        Map<Long, CrmCustomerDO> parentDealerMap = CollUtil.isEmpty(parentDealerIds)
                ? Collections.emptyMap() : customerService.getCustomerMap(parentDealerIds);
        // 2. è½¬æ¢æˆ VO
        return BeanUtils.toBean(list, CrmCustomerRespVO.class, customerVO -> {
            customerVO.setAreaName(AreaUtils.format(customerVO.getAreaId()));
            MapUtils.findAndThen(parentDealerMap, customerVO.getParentDealerId(),
                    dealer -> customerVO.setParentDealerName(dealer.getName()));
            // 2.1 è®¾ç½®åˆ›å»ºäººã€è´Ÿè´£äººåç§°
            MapUtils.findAndThen(userMap, NumberUtils.parseLong(customerVO.getCreator()),
                    user -> customerVO.setCreatorName(user.getNickname()));
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerPageReqVO.java
@@ -30,6 +30,24 @@
    @Schema(description = "客户名称", example = "赵六")
    private String name;
    @Schema(description = "社会信用代码", example = "91320600MA1XXXXXXX")
    private String socialCreditCode;
    @Schema(description = "身份证号", example = "320602199001011234")
    private String idCardNo;
    @Schema(description = "客户姓名", example = "张三")
    private String customerName;
    @Schema(description = "备案流水号", example = "BA2026000001")
    private String filingSerialNo;
    @Schema(description = "所属区域", example = "南通")
    private String region;
    @Schema(description = "所属上级经销商编号", example = "1024")
    private Long parentDealerId;
    @Schema(description = "手机", example = "18000000000")
    private String mobile;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerRespVO.java
@@ -23,6 +23,38 @@
    @ExcelProperty("客户名称")
    private String name;
    @Schema(description = "社会信用代码", example = "91320600MA1XXXXXXX")
    @ExcelProperty("社会信用代码")
    private String socialCreditCode;
    @Schema(description = "身份证号", example = "320602199001011234")
    @ExcelProperty("身份证号")
    private String idCardNo;
    @Schema(description = "客户姓名", example = "张三")
    @ExcelProperty("客户姓名")
    private String customerName;
    @Schema(description = "备案流水号", example = "BA2026000001")
    @ExcelProperty("备案流水号")
    private String filingSerialNo;
    @Schema(description = "银行账户名称", example = "南通某某农业有限公司")
    @ExcelProperty("银行账户名称")
    private String bankAccountName;
    @Schema(description = "所属区域", example = "南通")
    @ExcelProperty("所属区域")
    private String region;
    @Schema(description = "所属上级经销商编号", example = "1024")
    @ExcelProperty("所属上级经销商编号")
    private Long parentDealerId;
    @Schema(description = "所属上级经销商名称", example = "某某经销商")
    @ExcelProperty("所属上级经销商")
    private String parentDealerName;
    @Schema(description = "跟进状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563")
    @ExcelProperty(value = "跟进状态", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.BOOLEAN_STRING)
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerSaveReqVO.java
@@ -35,6 +35,40 @@
    @NotEmpty(message = "客户名称不能为空")
    private String name;
    @Schema(description = "社会信用代码", example = "91320600MA1XXXXXXX")
    @DiffLogField(name = "社会信用代码")
    @Size(max = 64, message = "社会信用代码长度不能超过 64 ä¸ªå­—符")
    private String socialCreditCode;
    @Schema(description = "身份证号", example = "320602199001011234")
    @DiffLogField(name = "身份证号")
    @Size(max = 32, message = "身份证号长度不能超过 32 ä¸ªå­—符")
    private String idCardNo;
    @Schema(description = "客户姓名", example = "张三")
    @DiffLogField(name = "客户姓名")
    @Size(max = 64, message = "客户姓名长度不能超过 64 ä¸ªå­—符")
    private String customerName;
    @Schema(description = "备案流水号", example = "BA2026000001")
    @DiffLogField(name = "备案流水号")
    @Size(max = 64, message = "备案流水号长度不能超过 64 ä¸ªå­—符")
    private String filingSerialNo;
    @Schema(description = "银行账户名称", example = "南通某某农业有限公司")
    @DiffLogField(name = "银行账户名称")
    @Size(max = 128, message = "银行账户名称长度不能超过 128 ä¸ªå­—符")
    private String bankAccountName;
    @Schema(description = "所属区域", example = "南通")
    @DiffLogField(name = "所属区域")
    @Size(max = 64, message = "所属区域长度不能超过 64 ä¸ªå­—符")
    private String region;
    @Schema(description = "所属上级经销商编号", example = "1024")
    @DiffLogField(name = "所属上级经销商编号")
    private Long parentDealerId;
    @Schema(description = "下次联系时间")
    @DiffLogField(name = "下次联系时间")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/customer/CrmCustomerDO.java
@@ -35,6 +35,37 @@
    private String name;
    /**
     * ç¤¾ä¼šä¿¡ç”¨ä»£ç 
     */
    private String socialCreditCode;
    /**
     * èº«ä»½è¯å·
     */
    private String idCardNo;
    /**
     * å®¢æˆ·å§“名
     */
    private String customerName;
    /**
     * å¤‡æ¡ˆæµæ°´å·
     */
    private String filingSerialNo;
    /**
     * é“¶è¡Œè´¦æˆ·åç§°
     */
    private String bankAccountName;
    /**
     * æ‰€å±žåŒºåŸŸ
     */
    private String region;
    /**
     * æ‰€å±žä¸Šçº§ç»é”€å•†ç¼–号
     *
     * å…³è” {@link CrmCustomerDO#getId()} å­—段
     */
    private Long parentDealerId;
    /**
     * è·Ÿè¿›çŠ¶æ€
     */
    private Boolean followUpStatus;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/customer/CrmCustomerMapper.java
@@ -64,6 +64,12 @@
        // æ‹¼æŽ¥è‡ªèº«çš„æŸ¥è¯¢æ¡ä»¶
        query.selectAll(CrmCustomerDO.class)
                .likeIfPresent(CrmCustomerDO::getName, pageReqVO.getName())
                .likeIfPresent(CrmCustomerDO::getSocialCreditCode, pageReqVO.getSocialCreditCode())
                .likeIfPresent(CrmCustomerDO::getIdCardNo, pageReqVO.getIdCardNo())
                .likeIfPresent(CrmCustomerDO::getCustomerName, pageReqVO.getCustomerName())
                .likeIfPresent(CrmCustomerDO::getFilingSerialNo, pageReqVO.getFilingSerialNo())
                .likeIfPresent(CrmCustomerDO::getRegion, pageReqVO.getRegion())
                .eqIfPresent(CrmCustomerDO::getParentDealerId, pageReqVO.getParentDealerId())
                .eqIfPresent(CrmCustomerDO::getMobile, pageReqVO.getMobile())
                .eqIfPresent(CrmCustomerDO::getIndustryId, pageReqVO.getIndustryId())
                .eqIfPresent(CrmCustomerDO::getLevel, pageReqVO.getLevel())
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -72,6 +72,7 @@
    ErrorCode CUSTOMER_CREATE_NAME_NOT_NULL = new ErrorCode(1_020_006_013, "客户名称不能为空!");
    ErrorCode CUSTOMER_NAME_EXISTS = new ErrorCode(1_020_006_014, "已存在名为【{}】的客户!");
    ErrorCode CUSTOMER_UPDATE_DEAL_STATUS_FAIL = new ErrorCode(1_020_006_015, "更新客户的成交状态失败,原因:已经是该状态,无需更新");
    ErrorCode CUSTOMER_PARENT_DEALER_SELF = new ErrorCode(1_020_006_016, "所属上级经销商不能选择客户自身");
    // ========== æƒé™ç®¡ç† 1_020_007_000 ==========
    ErrorCode CRM_PERMISSION_NOT_EXISTS = new ErrorCode(1_020_007_000, "数据权限不存在");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerServiceImpl.java
@@ -131,6 +131,11 @@
        updateReqVO.setOwnerUserId(null);  // æ›´æ–°çš„æ—¶å€™ï¼Œè¦æŠŠ updateReqVO è´Ÿè´£äººè®¾ç½®ä¸ºç©ºï¼Œé¿å…ä¿®æ”¹
        // 1. æ ¡éªŒå­˜åœ¨
        CrmCustomerDO oldCustomer = validateCustomerExists(updateReqVO.getId());
        // 1.1 æ ¡éªŒæ‰€å±žä¸Šçº§ç»é”€å•†ä¸èƒ½æ˜¯è‡ªå·±
        if (updateReqVO.getParentDealerId() != null
                && updateReqVO.getParentDealerId().equals(updateReqVO.getId())) {
            throw exception(CUSTOMER_PARENT_DEALER_SELF);
        }
        // 2. æ›´æ–°å®¢æˆ·
        CrmCustomerDO updateObj = BeanUtils.toBean(updateReqVO, CrmCustomerDO.class);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/MesProBatchController.java
@@ -48,6 +48,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -246,9 +247,16 @@
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(list, MesProBatchDO::getOwnerUserId));
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(convertSet(list, MesProBatchDO::getItemId));
        // æ‰¹æ¬¡ä¸‹å·²ç”Ÿæˆçš„æ­£å¼æ‰˜ç›˜ç ï¼ˆæ‰¹é‡æŸ¥è¯¢ï¼Œé¿å…é€è¡ŒæŸ¥è¯¢ï¼‰
        Map<Long, List<String>> palletCodeMap = palletService.getPalletListByBatchIds(
                        convertSet(list, MesProBatchDO::getId)).stream()
                .filter(pallet -> StrUtil.isNotBlank(pallet.getCode()))
                .collect(Collectors.groupingBy(MesProPalletDO::getBatchId,
                        Collectors.mapping(MesProPalletDO::getCode, Collectors.toList())));
        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()); });
            vo.setPalletCodes(palletCodeMap.getOrDefault(vo.getId(), Collections.emptyList()));
        });
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/batch/vo/MesProBatchRespVO.java
@@ -83,6 +83,10 @@
    @ExcelProperty("托盘码数量")
    private Integer palletQuantity;
    @Schema(description = "批次下全部正式托盘码")
    @ExcelIgnore
    private List<String> palletCodes;
    @Schema(description = "批次状态(字典 mes_pro_batch_status)", example = "20")
    @ExcelProperty(value = "批次状态", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.MES_PRO_BATCH_STATUS)
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 = "批次号(可选,人工输入;不传则系统按「年份+3位流水」自动生成,如 2026001)", example = "2026001")
    private String code;
    @Schema(description = "品种编号,由绑定托盘或吨袋带入并经后端校验")
    private Long itemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/vo/MesProScanEventCreateReqVO.java
@@ -18,11 +18,12 @@
    private String scanType;
    @NotNull @DecimalMin("0.000001") private BigDecimal quantity;
    /**
     * è®¾å¤‡ç¼–码(不再必填,产线扫码以产线+库区库位定位)
     * è®¾å¤‡ç¼–码(不再必填,产线扫码以产线定位)
     */
    @Size(max = 64) private String deviceCode;
    private String lineCode;
    @NotNull private Long locationId;
    @NotNull private Long areaId;
    /** åº“位、库区非必填:扫码入库统一落到 WIP ç”Ÿäº§æš‚存仓,前端不再要求选择库区库位 */
    private Long locationId;
    private Long areaId;
    private String remark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/scan/vo/MesProScanEventRespVO.java
@@ -12,6 +12,8 @@
    private String bagCode;
    private String scanType;
    private String batchCode;
    private Long palletId;
    private String palletCode;
    private BigDecimal quantity;
    private String deviceCode;
    private String lineCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/MesQcSeedQualityController.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualitySubmitReqVO;
@@ -11,7 +12,9 @@
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchApprovalReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRetestReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityScopeRetestReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedReturnQualitySubmitReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedIndicatorRangeSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedIndicatorRangeRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityActionRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityActionDO;
@@ -41,27 +44,56 @@
    @Resource private MesQcSeedIndicatorRangeService rangeService;
    @Resource private MesProBagCodeMapper bagCodeMapper;
    @Resource private AdminUserApi adminUserApi;
    @GetMapping("/ranges") @Operation(summary = "获得种子质检合格范围")
    @GetMapping("/ranges") @Operation(summary = "获得产品质检项配置")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-range:query')")
    public CommonResult<java.util.List<cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedIndicatorRangeDO>> ranges() {
        return success(rangeService.getRanges());
    public CommonResult<List<MesQcSeedIndicatorRangeRespVO>> ranges(@RequestParam(required = false) Long itemId,
                                                                    @RequestParam(required = false) String keyword,
                                                                    @RequestParam(required = false) Integer status) {
        return success(BeanUtils.toBean(rangeService.getRanges(itemId, keyword, status), MesQcSeedIndicatorRangeRespVO.class));
    }
    @PutMapping("/ranges") @Operation(summary = "配置种子质检合格范围")
    @GetMapping("/ranges/effective") @Operation(summary = "获得产品生效的质检项")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-range:query')")
    public CommonResult<List<MesQcSeedIndicatorRangeRespVO>> effectiveRanges(@RequestParam Long itemId) {
        return success(BeanUtils.toBean(rangeService.getEffectiveRanges(itemId), MesQcSeedIndicatorRangeRespVO.class));
    }
    @PostMapping("/ranges") @Operation(summary = "新增产品质检项")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-range:update')")
    public CommonResult<Boolean> saveRanges(@Valid @RequestBody MesQcSeedIndicatorRangeSaveReqVO reqVO) {
        rangeService.saveRanges(reqVO);
    public CommonResult<Long> createRange(@Valid @RequestBody MesQcSeedIndicatorRangeSaveReqVO reqVO) {
        return success(rangeService.create(reqVO));
    }
    @PutMapping("/ranges/{id}") @Operation(summary = "修改产品质检项")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-range:update')")
    public CommonResult<Boolean> updateRange(@PathVariable Long id, @Valid @RequestBody MesQcSeedIndicatorRangeSaveReqVO reqVO) {
        rangeService.update(id, reqVO);
        return success(true);
    }
    @DeleteMapping("/ranges/{id}") @Operation(summary = "删除产品质检项")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-range:update')")
    public CommonResult<Boolean> deleteRange(@PathVariable Long id) {
        rangeService.delete(id);
        return success(true);
    }
    @PostMapping("/ranges/copy-default") @Operation(summary = "复制通用默认质检项到产品")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-range:update')")
    public CommonResult<Integer> copyDefaultRanges(@RequestParam Long itemId) {
        return success(rangeService.copyDefaults(itemId));
    }
    @GetMapping("/resolve")
    @Operation(summary = "解析待质检袋码及确认该袋质检结果")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve')")
    public CommonResult<java.util.Map<String, Object>> resolve(@RequestParam String code, @RequestParam(defaultValue = "BAG") String scanType) {
        return success(service.resolveScan(code, scanType));
    public CommonResult<java.util.Map<String, Object>> resolve(@RequestParam String code, @RequestParam(defaultValue = "BAG") String scanType,
                                                                @RequestParam(defaultValue = "1") Integer qcLine) {
        return success(service.resolveScan(code, scanType, qcLine));
    }
    @GetMapping("/page")
    @Operation(summary = "获得种子质检记录分页")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:query')")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:query', 'mes:qc-seed-quality:submit', "
            + "'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve')")
    public CommonResult<PageResult<MesQcSeedQualityRespVO>> getQualityPage(@Valid MesQcSeedQualityPageReqVO pageReqVO) {
        PageResult<MesQcSeedQualityRespVO> page = BeanUtils.toBean(
                service.getQualityPage(pageReqVO), MesQcSeedQualityRespVO.class);
@@ -78,13 +110,22 @@
        return success(page);
    }
    @GetMapping("/indicators") @Operation(summary = "获得种子质检最新指标")
    @GetMapping("/batch-page")
    @Operation(summary = "获得种子质检记录按生产批次分组分页")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:query')")
    public CommonResult<PageResult<MesQcSeedQualityBatchRespVO>> getQualityBatchPage(@Valid MesQcSeedQualityPageReqVO pageReqVO) {
        return success(service.getQualityBatchPage(pageReqVO));
    }
    @GetMapping("/indicators") @Operation(summary = "获得种子质检最新指标")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:query', 'mes:qc-seed-quality:submit', "
            + "'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve')")
    public CommonResult<java.util.List<cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityIndicatorDO>> indicators(@RequestParam Long id) {
        return success(service.getIndicatorsByQualityId(id));
    }
    @GetMapping("/actions") @Operation(summary = "获得种子质检流程留痕")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:query')")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:query', 'mes:qc-seed-quality:submit', "
            + "'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve')")
    public CommonResult<List<MesQcSeedQualityActionRespVO>> actions(@RequestParam Long id) {
        List<MesQcSeedQualityActionDO> actions = service.getActions(id);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(actions.stream()
@@ -106,6 +147,13 @@
        return success(service.submitGroup(reqVO));
    }
    @PostMapping("/retry-bpm") @Operation(summary = "重新发起质检 BPM å®¡æ‰¹")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:process-approve')")
    public CommonResult<Boolean> retryBpm(@RequestParam Long id) {
        service.retryBpmProcess(id);
        return success(true);
    }
    @PostMapping("/retest") @Operation(summary = "种子质检复检")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:retest')")
    public CommonResult<Boolean> retest(@RequestParam Long id, @Valid @RequestBody MesQcSeedQualityRetestReqVO reqVO) {
@@ -122,8 +170,9 @@
    @GetMapping("/retest-scope/resolve") @Operation(summary = "按托盘码或生产批次码解析待批量质检记录")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve')")
    public CommonResult<java.util.Map<String, Object>> resolveRetestScope(@RequestParam String code, @RequestParam String scanType) {
        return success(service.resolveRetestScope(code, scanType));
    public CommonResult<java.util.Map<String, Object>> resolveRetestScope(@RequestParam String code, @RequestParam String scanType,
                                                                          @RequestParam(defaultValue = "1") Integer qcLine) {
        return success(service.resolveRetestScope(code, scanType, qcLine));
    }
    @PostMapping("/retest-scope") @Operation(summary = "按托盘码或生产批次码批量复检或重新初检")
@@ -157,4 +206,23 @@
        service.companyApproveBatch(reqVO.getIds(), reqVO.getApproved(), reqVO.getOpinion());
        return success(true);
    }
    @GetMapping("/return/resolve") @Operation(summary = "解析经销商退货单待质检信息")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve')")
    public CommonResult<Map<String, Object>> resolveReturnQuality(@RequestParam Long returnSalesId) {
        return success(service.resolveReturnQuality(returnSalesId));
    }
    @PostMapping("/return/submit") @Operation(summary = "提交经销商退货质检")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:submit')")
    public CommonResult<Long> submitReturnQuality(@Valid @RequestBody MesQcSeedReturnQualitySubmitReqVO reqVO) {
        return success(service.submitReturnQuality(reqVO));
    }
    @PostMapping("/return/approve") @Operation(summary = "经销商退货质检销售部审批")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:process-approve')")
    public CommonResult<Boolean> returnApprove(@Valid @RequestBody MesQcSeedQualityApprovalReqVO reqVO) {
        service.returnApprove(reqVO.getId(), reqVO.getApproved(), reqVO.getOpinion());
        return success(true);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedIndicatorRangeRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class MesQcSeedIndicatorRangeRespVO {
    private Long id;
    private Long itemId;
    private String indicatorCode;
    private String indicatorName;
    private String unit;
    private BigDecimal minValue;
    private BigDecimal maxValue;
    private Integer sort;
    /** çŠ¶æ€ï¼š0 å¯ç”¨ 1 åœç”¨ */
    private Integer status;
    private LocalDateTime updateTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedIndicatorRangeSaveReqVO.java
@@ -1,25 +1,32 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class MesQcSeedIndicatorRangeSaveReqVO {
    @NotEmpty
    private List<@Valid Item> ranges;
    @Data
    public static class Item {
        @NotBlank private String indicatorCode;
        @NotNull @DecimalMin("0") @DecimalMax("100") private BigDecimal minValue;
        @NotNull @DecimalMin("0") @DecimalMax("100") private BigDecimal maxValue;
    }
    /** ç»‘定产品(mdm_item,itemType=2) */
    @NotNull
    private Long itemId;
    @NotBlank
    @Pattern(regexp = "[A-Za-z][A-Za-z0-9_]{0,63}", message = "指标编码必须以字母开头,仅可包含字母、数字和下划线")
    private String indicatorCode;
    @NotBlank
    private String indicatorName;
    /** å•位,如 %、粒/公斤 */
    private String unit;
    @NotNull
    @DecimalMin("0")
    private BigDecimal minValue;
    @NotNull
    @DecimalMin("0")
    private BigDecimal maxValue;
    private Integer sort;
    /** çŠ¶æ€ï¼š0 å¯ç”¨ 1 åœç”¨ */
    private Integer status;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityBatchRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - MES ç§å­è´¨æ£€è®°å½•按生产批次分组 Response VO")
@Data
public class MesQcSeedQualityBatchRespVO {
    @Schema(description = "生产批次编号;未绑定批次时为空")
    private Long batchId;
    @Schema(description = "生产批次号;未绑定时为空字符串")
    private String batchCode;
    @Schema(description = "批次展示名称,未绑定时为「未绑定批次」")
    private String batchLabel;
    @Schema(description = "品种编号")
    private Long itemId;
    @Schema(description = "品种编码")
    private String itemCode;
    @Schema(description = "品种名称")
    private String itemName;
    @Schema(description = "加工单位(产线名称)")
    private String lineName;
    @Schema(description = "生产日期")
    private LocalDateTime produceDate;
    @Schema(description = "组内质检记录总数")
    private Integer totalCount;
    @Schema(description = "合格数量")
    private Integer passCount;
    @Schema(description = "不合格数量")
    private Integer failCount;
    @Schema(description = "处理中数量")
    private Integer processingCount;
    @Schema(description = "待复检/重新初检数量")
    private Integer pendingRetestCount;
    @Schema(description = "待初检审核数量")
    private Integer pendingProcessReviewCount;
    @Schema(description = "待复检审核数量")
    private Integer pendingCompanyReviewCount;
    @Schema(description = "已放行数量")
    private Integer releasedCount;
    @Schema(description = "已报废数量")
    private Integer scrappedCount;
    @Schema(description = "组内最近检验时间")
    private LocalDateTime lastInspectedAt;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityPageReqVO.java
@@ -13,12 +13,24 @@
    @Schema(description = "质检袋码")
    private String code;
    @Schema(description = "生产批次")
    @Schema(description = "生产批次(模糊匹配)")
    private String batchCode;
    @Schema(description = "生产批次(精确匹配,按批次展开明细时使用)")
    private String exactBatchCode;
    @Schema(description = "是否仅查询未绑定生产批次的记录(按「未绑定批次」展开明细时使用)")
    private Boolean unbound;
    @Schema(description = "检验结果:1合格 2不合格")
    private Integer result;
    @Schema(description = "流程状态:10待复检 20/50待加工部 30/60待公司质检 40已放行 70已报废")
    private Integer workflowStatus;
    @Schema(description = "质检线:1加工部 2销售部 0未区分(历史数据)")
    private Integer qcLine;
    @Schema(description = "业务类型:1生产入库质检 2经销商退货质检")
    private Integer bizType;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityRespVO.java
@@ -11,6 +11,10 @@
    private Long id;
    private String code;
    @Schema(description = "质检线:1加工部 2销售部 0未区分(历史数据)")
    private Integer qcLine;
    @Schema(description = "业务类型:1生产入库质检 2经销商退货质检")
    private Integer bizType;
    @Schema(description = "对应托盘码;未绑定托盘时为空")
    private String palletCode;
    private Long itemId;
@@ -18,9 +22,15 @@
    private String batchCode;
    private Integer result;
    private Integer workflowStatus;
    private String processInstanceId;
    private String companyProcessInstanceId;
    private Long returnSalesId;
    private String returnSalesCode;
    private Integer retestResult;
    private LocalDateTime retestedAt;
    private LocalDateTime processApprovedAt;
    private LocalDateTime lineFinishedAt;
    private LocalDateTime companySubmittedAt;
    private LocalDateTime companyApprovedAt;
    private String reason;
    private Long inspectorId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityScopeRetestReqVO.java
@@ -11,6 +11,7 @@
public class MesQcSeedQualityScopeRetestReqVO {
    @NotBlank private String code;
    @NotBlank private String scanType;
    @NotNull private Integer qcLine;
    @NotNull private Integer workflowStatus;
    @NotEmpty private List<@NotNull Long> qualityIds;
    @NotEmpty @Valid private List<MesQcSeedQualitySubmitReqVO.Indicator> indicators;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualitySubmitReqVO.java
@@ -9,6 +9,8 @@
@Data
public class MesQcSeedQualitySubmitReqVO {
    @NotBlank private String code;
    /** å…¼å®¹å­—段:一次提交会同时生成加工部与销售部两条记录,后端不再按该字段拆分。 */
    private Integer qcLine;
    private String batchCode;
    @NotEmpty @Valid private List<Indicator> indicators;
    private String reason;
@@ -16,8 +18,8 @@
    @NotNull private Long qualifiedWarehouseId;
    @Data public static class Indicator {
        @NotBlank private String code;
        @NotNull @DecimalMin("0") @DecimalMax("100") private BigDecimal value;
        @NotNull @DecimalMin("0") @DecimalMax("100") private BigDecimal minValue;
        @NotNull @DecimalMin("0") @DecimalMax("100") private BigDecimal maxValue;
        @NotNull @DecimalMin("0") private BigDecimal value;
        @NotNull @DecimalMin("0") private BigDecimal minValue;
        @NotNull @DecimalMin("0") private BigDecimal maxValue;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedReturnQualitySubmitReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
 * ç®¡ç†åŽå° - ç»é”€å•†é€€è´§ç§å­è´¨æ£€æäº¤ Request VO
 */
@Data
public class MesQcSeedReturnQualitySubmitReqVO {
    @NotNull
    private Long returnSalesId;
    @NotEmpty
    @Valid
    private List<MesQcSeedQualitySubmitReqVO.Indicator> indicators;
    /** ä¸åˆæ ¼åŽŸå›  */
    private String reason;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/batch/vo/MesWmBatchSalesTargetRespVO.java
@@ -12,6 +12,7 @@
    @Schema(description = "销售出库单号", example = "SO20250801001")
    private String salesCode;
    private Long shipmentId;
    @Schema(description = "出库日期")
    private LocalDateTime salesDate;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/MesWmMaterialStockController.java
@@ -20,6 +20,8 @@
import cn.iocoder.yudao.module.mes.controller.admin.wm.materialstock.vo.MesWmStockSummaryRespVO;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.mdm.api.category.MdmItemCategoryApi;
import cn.iocoder.yudao.module.mdm.api.category.dto.MdmItemCategoryRespDTO;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
@@ -63,9 +65,14 @@
public class MesWmMaterialStockController {
    @Resource
    private cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper transactionMapper;
    @Resource
    private MesWmMaterialStockService materialStockService;
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmItemCategoryApi mdmItemCategoryApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
@@ -78,6 +85,20 @@
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private MesWmMaterialStockBagMapper materialStockBagMapper;
    @Resource
    private cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService productSalesLineService;
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper salesDetailMapper;
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper salesMapper;
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesShipmentMapper shipmentMapper;
    @GetMapping("/picking-list")
    @Operation(summary = "按销售出库单行查询合格库存")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:stock') and @ss.hasPermission('mes:wm-material-stock:query')")
    public CommonResult<List<MesWmMaterialStockRespVO>> getPickingStocks(@RequestParam("lineId") Long lineId) {
        var line = productSalesLineService.getProductSalesLine(lineId);
        if (line == null) throw new IllegalArgumentException("出库单行不存在");
        return success(buildRespVOList(materialStockService.getQualifiedMaterialStockListByItemAndBatch(line.getItemId(), line.getBatchId())));
    }
    @GetMapping("/get")
    @Operation(summary = "获得库存记录")
@@ -108,7 +129,37 @@
        PageResult<MesWmMaterialStockBagRespVO> result = new PageResult<>();
        PageResult<cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstockbag.MesWmMaterialStockBagDO> page =
                materialStockBagMapper.selectPage(pageReqVO);
        return success(new PageResult<>(BeanUtils.toBean(page.getList(), MesWmMaterialStockBagRespVO.class), page.getTotal()));
        var transactionIds = convertSet(page.getList(), cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstockbag.MesWmMaterialStockBagDO::getLastTransactionId);
        Map<Long, cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO> transactions = transactionIds.isEmpty()
                ? Collections.emptyMap() : transactionMapper.selectBatchIds(transactionIds).stream()
                .collect(Collectors.toMap(cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO::getId, tx -> tx));
        var rows = BeanUtils.toBean(page.getList(), MesWmMaterialStockBagRespVO.class);
        var bagIds = convertSet(page.getList(), cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstockbag.MesWmMaterialStockBagDO::getBagCodeId);
        var pickingDetails = salesDetailMapper.selectByBagIds(bagIds);
        var saleIds = convertSet(pickingDetails, cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO::getSalesId);
        var shipmentIds = convertSet(pickingDetails, cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO::getShipmentId);
        var saleMap = saleIds.isEmpty() ? Collections.<Long, cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO>emptyMap()
                : salesMapper.selectBatchIds(saleIds).stream().collect(Collectors.toMap(cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO::getId, s -> s));
        var shipmentMap = shipmentIds.isEmpty() ? Collections.<Long, cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO>emptyMap()
                : shipmentMapper.selectBatchIds(shipmentIds).stream().collect(Collectors.toMap(cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO::getId, s -> s));
        Map<Long, String> pickingByBag = new java.util.HashMap<>();
        for (var detail : pickingDetails) {
            var sale = saleMap.get(detail.getSalesId());
            var shipment = detail.getShipmentId() == null ? null : shipmentMap.get(detail.getShipmentId());
            if (sale == null) continue;
            if (detail.getShipmentId() == null && Integer.valueOf(2).equals(sale.getStatus())) pickingByBag.putIfAbsent(detail.getBagCodeId(), "PICKED");
            else if ((detail.getShipmentId() == null && (Integer.valueOf(10).equals(sale.getStatus()) || Integer.valueOf(3).equals(sale.getStatus())))
                    || (shipment != null && (Integer.valueOf(10).equals(shipment.getStatus()) || Integer.valueOf(20).equals(shipment.getStatus())))) pickingByBag.put(detail.getBagCodeId(), "RESERVED");
        }
        for (int i = 0; i < rows.size(); i++) {
            var bag = page.getList().get(i);
            var tx = bag.getLastTransactionId() == null ? null : transactions.get(bag.getLastTransactionId());
            if (Integer.valueOf(2).equals(bag.getStatus()) && tx != null && Integer.valueOf(2).equals(tx.getType())) {
                rows.get(i).setOutboundBizType(tx.getBizType());
            }
            if (Integer.valueOf(1).equals(bag.getStatus())) rows.get(i).setPickingStatus(pickingByBag.get(bag.getBagCodeId()));
        }
        return success(new PageResult<>(rows, page.getTotal()));
    }
    @PutMapping("/update-frozen")
@@ -187,6 +238,9 @@
        // 1. èŽ·å¾—å…³è”æ•°æ®
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(
                convertSet(list, MesWmMaterialStockDO::getItemId));
        Map<Long, MdmItemCategoryRespDTO> itemCategoryMap = mdmItemCategoryApi.getItemCategoryList(
                convertSet(itemMap.values(), MdmItemRespDTO::getCategoryId)).getCheckedData().stream()
                .collect(Collectors.toMap(MdmItemCategoryRespDTO::getId, c -> c));
        Map<Long, MdmUnitMeasureRespDTO> unitMeasureMap = mdmUnitMeasureApi.getUnitMeasureList(
                convertSet(itemMap.values(), MdmItemRespDTO::getUnitMeasureId)).getCheckedData()
                .stream().collect(Collectors.toMap(MdmUnitMeasureRespDTO::getId, e -> e));
@@ -196,9 +250,11 @@
                convertSet(list, MesWmMaterialStockDO::getLocationId));
        Map<Long, MesWmWarehouseAreaDO> areaMap = areaService.getWarehouseAreaMap(
                convertSet(list, MesWmMaterialStockDO::getAreaId));
        Map<Long, Long> bagCountMap = materialStockBagMapper.selectListByMaterialStockIds(
                convertSet(list, MesWmMaterialStockDO::getId)).stream()
        List<MesWmMaterialStockBagDO> bagHistory = materialStockBagMapper.selectHistoryByMaterialStockIds(
                convertSet(list, MesWmMaterialStockDO::getId));
        Map<Long, Long> bagCountMap = bagHistory.stream().filter(bag -> Integer.valueOf(1).equals(bag.getStatus()))
                .collect(Collectors.groupingBy(MesWmMaterialStockBagDO::getMaterialStockId, Collectors.counting()));
        var stockIdsWithBagHistory = convertSet(bagHistory, MesWmMaterialStockBagDO::getMaterialStockId);
        Map<Long, SrmSupplierRespDTO> vendorMap = srmSupplierApi.getSupplierList(
                convertSet(list, MesWmMaterialStockDO::getVendorId)).getCheckedData().stream()
                .collect(Collectors.toMap(SrmSupplierRespDTO::getId, s -> s));
@@ -206,6 +262,8 @@
        return BeanUtils.toBean(list, MesWmMaterialStockRespVO.class, vo -> {
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setSpecification(item.getSpecification());
                MapUtils.findAndThen(itemCategoryMap, item.getCategoryId(),
                        category -> vo.setItemTypeName(category.getName()));
                MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
                        unitMeasure -> vo.setUnitMeasureName(unitMeasure.getName()));
            });
@@ -218,7 +276,7 @@
            MapUtils.findAndThen(vendorMap, vo.getVendorId(),
                    vendor -> vo.setVendorName(vendor.getName()));
            vo.setBagCount(bagCountMap.getOrDefault(vo.getId(), 0L));
            vo.setBagDetailSupported(bagCountMap.containsKey(vo.getId()));
            vo.setBagDetailSupported(stockIdsWithBagHistory.contains(vo.getId()));
            // è®¡ç®—可用量 = åœ¨åº“数量 - å†»ç»“数量 - å ç”¨é‡
            BigDecimal quantity = vo.getQuantity() != null ? vo.getQuantity() : BigDecimal.ZERO;
            BigDecimal frozenQty = vo.getFrozenQuantity() != null ? vo.getFrozenQuantity() : BigDecimal.ZERO;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockAdjustReqVO.java
@@ -27,12 +27,10 @@
    @NotNull(message = "仓库ID不能为空")
    private Long warehouseId;
    @Schema(description = "库区ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库区ID不能为空")
    @Schema(description = "库区ID", example = "1")
    private Long locationId;
    @Schema(description = "库位ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库位ID不能为空")
    @Schema(description = "库位ID", example = "1")
    private Long areaId;
    // ========== æ‰¹æ¬¡ç›¸å…³å­—段 ==========
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockBagPageReqVO.java
@@ -15,4 +15,7 @@
    @Schema(description = "袋码")
    private String bagCode;
    @Schema(description = "包含已出库和已报废袋码")
    private Boolean includeHistory;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockBagRespVO.java
@@ -26,4 +26,8 @@
    private Boolean frozen;
    private LocalDateTime inboundTime;
    private LocalDateTime outboundTime;
    @Schema(description = "最近一次出库的业务类型;仅在袋码已出库且存在关联流水时返回")
    private Integer outboundBizType;
    @Schema(description = "销售拣货状态:PICKED已拣选、RESERVED已占用")
    private String pickingStatus;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockPageReqVO.java
@@ -57,4 +57,7 @@
    @Schema(description = "是否冻结", example = "false")
    private Boolean frozen;
    @Schema(description = "包含零数量库存记录,用于查询历史")
    private Boolean includeZero;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/materialstock/vo/MesWmMaterialStockRespVO.java
@@ -35,6 +35,10 @@
    @ExcelProperty("规格型号")
    private String specification;
    @Schema(description = "物料类别名称", example = "五金配件")
    @ExcelProperty("类别")
    private String itemTypeName;
    @Schema(description = "计量单位名称", example = "千克")
    @ExcelProperty("计量单位")
    private String unitMeasureName;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/miscissue/vo/line/MesWmMiscIssueLineSaveReqVO.java
@@ -43,12 +43,10 @@
    @NotNull(message = "仓库不能为空")
    private Long warehouseId;
    @Schema(description = "库区编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库区不能为空")
    @Schema(description = "库区编号", example = "1")
    private Long locationId;
    @Schema(description = "库位编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库位不能为空")
    @Schema(description = "库位编号", example = "1")
    private Long areaId;
    @Schema(description = "备注", example = "备注")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/miscreceipt/vo/line/MesWmMiscReceiptLineSaveReqVO.java
@@ -34,12 +34,10 @@
    @NotNull(message = "仓库不能为空")
    private Long warehouseId;
    @Schema(description = "库区编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库区不能为空")
    @Schema(description = "库区编号", example = "1")
    private Long locationId;
    @Schema(description = "库位编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库位不能为空")
    @Schema(description = "库位编号", example = "1")
    private Long areaId;
    @Schema(description = "备注", example = "备注")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesController.java
@@ -44,6 +44,7 @@
@RequestMapping("/mes/wm/product-sales")
@Validated
public class MesWmProductSalesController {
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesShipmentMapper shipmentMapper;
    @Resource
    private MesWmProductSalesService productSalesService;
@@ -128,6 +129,13 @@
        return success(productSalesService.checkProductSalesQuantity(id));
    }
    @GetMapping("/shipments")
    @Operation(summary = "查询出库单发货记录")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:query')")
    public CommonResult<List<cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO>> getShipments(@RequestParam("id") Long id) {
        return success(productSalesService.getShipments(id));
    }
    @PostMapping("/scan")
    @Operation(summary = "扫码拣货")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:stock')")
@@ -179,6 +187,22 @@
        return success(true);
    }
    @PutMapping("/close-remaining")
    @Operation(summary = "关闭部分发货单的剩余数量")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:cancel')")
    public CommonResult<Boolean> closeRemaining(@RequestParam("id") Long id, @RequestParam("reason") String reason) {
        productSalesService.closeRemaining(id, reason);
        return success(true);
    }
    @PutMapping("/cancel-shipment")
    @Operation(summary = "取消本次未出库发货并释放占用")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:cancel')")
    public CommonResult<Boolean> cancelShipment(@RequestParam("id") Long id) {
        productSalesService.cancelShipment(id);
        return success(true);
    }
    // ==================== æ‹¼æŽ¥ VO ====================
    private List<MesWmProductSalesRespVO> buildRespVOList(List<MesWmProductSalesDO> list) {
@@ -192,8 +216,12 @@
                convertSet(list, MesWmProductSalesDO::getNoticeId));
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(list, MesWmProductSalesDO::getOutboundUserId));
        var partiallyShippedIds = convertSet(shipmentMapper.selectCompletedBySalesIds(convertSet(list, MesWmProductSalesDO::getId)),
                cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO::getSalesId);
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesWmProductSalesRespVO.class, vo -> {
            vo.setPartiallyShipped(partiallyShippedIds.contains(vo.getId()) && (Integer.valueOf(2).equals(vo.getStatus())
                    || Integer.valueOf(10).equals(vo.getStatus()) || Integer.valueOf(3).equals(vo.getStatus())));
            MapUtils.findAndThen(customerMap, vo.getClientId(), customer ->
                    vo.setClientName(customer.getName()));
            if (vo.getClientName() == null) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesDetailController.java
@@ -63,6 +63,15 @@
        return success(productSalesDetailService.createProductSalesDetail(createReqVO));
    }
    @PostMapping("/create-mobile-manual")
    @Operation(summary = "移动端按数量拣选无袋码历史库存")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:stock')")
    public CommonResult<Long> createMobileManual(@Valid @RequestBody MesWmProductSalesDetailSaveReqVO reqVO) {
        if (reqVO.getRemark() == null || !reqVO.getRemark().matches("移动端无袋码拣货 #[a-f0-9-]{36}"))
            throw new IllegalArgumentException("缺少有效的拣货请求标识");
        return success(productSalesDetailService.createProductSalesDetail(reqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "修改销售出库明细")
    @PreAuthorize("@ss.hasPermission('mes:wm-product-sales:update')")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/MesWmProductSalesLineController.java
@@ -40,6 +40,7 @@
@RequestMapping("/mes/wm/product-sales-line")
@Validated
public class MesWmProductSalesLineController {
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper transactionMapper;
    @Resource
    private MesWmProductSalesLineService productSalesLineService;
@@ -108,6 +109,9 @@
                .stream().collect(Collectors.toMap(MdmUnitMeasureRespDTO::getId, e -> e));
        // 2. æž„建结果
        return BeanUtils.toBean(list, MesWmProductSalesLineRespVO.class, vo -> {
            var shipped = transactionMapper.sumSalesOutboundByLineId(vo.getId());
            vo.setShippedQuantity(shipped == null ? java.math.BigDecimal.ZERO : shipped);
            vo.setRemainingQuantity((vo.getQuantity() == null ? java.math.BigDecimal.ZERO : vo.getQuantity()).subtract(vo.getShippedQuantity()).max(java.math.BigDecimal.ZERO));
            MapUtils.findAndThen(itemMap, vo.getItemId(), item -> {
                vo.setItemCode(item.getCode()).setItemName(item.getName()).setSpecification(item.getSpecification());
                MapUtils.findAndThen(unitMeasureMap, item.getUnitMeasureId(),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesRespVO.java
@@ -76,6 +76,9 @@
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    @ExcelProperty("状态")
    private Integer status;
    private String closeReason;
    @Schema(description = "是否已部分发货且仍有后续处理")
    private Boolean partiallyShipped;
    @Schema(description = "备注", example = "备注")
    @ExcelProperty("备注")
@@ -85,4 +88,4 @@
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanReqVO.java
@@ -29,4 +29,13 @@
    @Schema(description = "本次明确选中的袋码 ID;托盘和批次必须选择")
    private List<@NotNull Long> bagIds;
    @Schema(description = "手工选择时指定出库单行")
    private Long lineId;
    @Schema(description = "手工选择时指定库存记录")
    private Long materialStockId;
    @Schema(description = "是否为手工选择袋码")
    private Boolean manual;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/MesWmProductSalesScanRespVO.java
@@ -82,6 +82,7 @@
        private String code;
        private String businessCode;
        private Long palletId;
        private Long materialStockId;
        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
@@ -19,6 +19,9 @@
    @Schema(description = "出库单ID", example = "1")
    private Long salesId;
    @Schema(description = "发货记录ID;为空表示本次待拣或历史明细")
    private Long shipmentId;
    @Schema(description = "物料ID", example = "1")
    private Long itemId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/productsales/vo/line/MesWmProductSalesLineRespVO.java
@@ -36,6 +36,10 @@
    @Schema(description = "出库数量", example = "100")
    private BigDecimal quantity;
    @Schema(description = "实际已出库数量")
    private BigDecimal shippedQuantity;
    @Schema(description = "剩余未出库数量")
    private BigDecimal remainingQuantity;
    @Schema(description = "批次ID", example = "1")
    private Long batchId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnissue/vo/detail/MesWmReturnIssueDetailSaveReqVO.java
@@ -44,12 +44,10 @@
    @NotNull(message = "仓库编号不能为空")
    private Long warehouseId;
    @Schema(description = "库区编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库区编号不能为空")
    @Schema(description = "库区编号", example = "1")
    private Long locationId;
    @Schema(description = "库位编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库位编号不能为空")
    @Schema(description = "库位编号", example = "1")
    private Long areaId;
    @Schema(description = "备注", example = "备注")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/detail/MesWmReturnSalesDetailSaveReqVO.java
@@ -46,12 +46,10 @@
    @NotNull(message = "仓库ID不能为空")
    private Long warehouseId;
    @Schema(description = "库区ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库区ID不能为空")
    @Schema(description = "库区ID", example = "1")
    private Long locationId;
    @Schema(description = "库位ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库位ID不能为空")
    @Schema(description = "库位ID", example = "1")
    private Long areaId;
    @Schema(description = "备注", example = "备注")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/stocktaking/task/vo/result/MesWmStockTakingTaskResultSaveReqVO.java
@@ -37,12 +37,10 @@
    @NotNull(message = "仓库 ID ä¸èƒ½ä¸ºç©º")
    private Long warehouseId;
    @Schema(description = "库区 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库区 ID ä¸èƒ½ä¸ºç©º")
    @Schema(description = "库区 ID", example = "1")
    private Long locationId;
    @Schema(description = "库位 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "库位 ID ä¸èƒ½ä¸ºç©º")
    @Schema(description = "库位 ID", example = "1")
    private Long areaId;
    @Schema(description = "盘点数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "100.000000")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transaction/MesWmTransactionController.java
@@ -1,22 +1,35 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.transaction;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.transaction.vo.MesWmTransactionPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.transaction.vo.MesWmTransactionRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemTypeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdItemMapper;
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.wm.productsales.MesWmProductSalesDetailMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemTypeService;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import cn.iocoder.yudao.module.mdm.api.category.MdmItemCategoryApi;
import cn.iocoder.yudao.module.mdm.api.category.dto.MdmItemCategoryRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -28,6 +41,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -47,11 +61,18 @@
    @Resource
    private MesMdmItemService mdmItemService;
    @Resource
    private MdmItemCategoryApi mdmItemCategoryApi;
    @Resource
    private MesWmWarehouseService warehouseService;
    @Resource
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource private MesProBagCodeMapper bagCodeMapper;
    @Resource private MesProPalletMapper palletMapper;
    @Resource private MesWmProductSalesDetailMapper salesDetailMapper;
    @Resource private MesMdItemMapper mesMdItemMapper;
    @Resource private MesMdItemTypeService mesMdItemTypeService;
    @GetMapping("/page")
    @Operation(summary = "获取库存事务流水分页")
@@ -66,6 +87,19 @@
        // å…³è”查询物料信息
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(
                convertSet(pageResult.getList(), MesWmTransactionDO::getItemId));
        // å…³è”查询物料类别信息
        Map<Long, MdmItemCategoryRespDTO> itemCategoryMap = mdmItemCategoryApi.getItemCategoryList(
                convertSet(itemMap.values(), MesMdmItemDO::getCategoryId)).getCheckedData().stream()
                .collect(Collectors.toMap(MdmItemCategoryRespDTO::getId, c -> c));
        // å…³è”查询 MES ç‰©æ–™ï¼ˆå“ç§åŒæ­¥ç”Ÿæˆï¼‰ï¼Œå“ç§æœªç»´æŠ¤è§„æ ¼/分类时回退使用
        Set<Long> mdmItemIds = convertSet(itemMap.values(), MesMdmItemDO::getId);
        Map<Long, MesMdItemDO> mesItemMap = mdmItemIds.isEmpty() ? Map.of()
                : mesMdItemMapper.selectList(MesMdItemDO::getMdmItemId, mdmItemIds).stream()
                .collect(Collectors.toMap(MesMdItemDO::getMdmItemId, i -> i, (a, b) -> a));
        Set<Long> mesItemTypeIds = convertSet(mesItemMap.values(), MesMdItemDO::getItemTypeId);
        mesItemTypeIds.remove(null);
        Map<Long, MesMdItemTypeDO> mesItemTypeMap = mesItemTypeIds.isEmpty() ? Map.of()
                : mesMdItemTypeService.getItemTypeMap(mesItemTypeIds);
        // å…³è”查询仓库信息
        Map<Long, MesWmWarehouseDO> warehouseMap = warehouseService.getWarehouseMap(
                convertSet(pageResult.getList(), MesWmTransactionDO::getWarehouseId));
@@ -76,9 +110,34 @@
        Map<Long, MesWmWarehouseAreaDO> areaMap = areaService.getWarehouseAreaMap(
                convertSet(pageResult.getList(), MesWmTransactionDO::getAreaId));
        var bagIds = convertSet(pageResult.getList(), MesWmTransactionDO::getBagCodeId);
        bagIds.remove(null);
        Map<Long, MesProBagCodeDO> bagMap = bagIds.isEmpty() ? Map.of() : bagCodeMapper.selectBatchIds(bagIds)
                .stream().collect(Collectors.toMap(MesProBagCodeDO::getId, b -> b));
        var salesIds = pageResult.getList().stream().filter(t -> Integer.valueOf(MesBizTypeConstants.WM_PRODUCT_SALES).equals(t.getBizType()))
                .map(MesWmTransactionDO::getBizId).filter(java.util.Objects::nonNull).collect(Collectors.toSet());
        var details = salesDetailMapper.selectBySalesIdsAndBagIds(salesIds, bagIds);
        var palletIds = convertSet(details, MesWmProductSalesDetailDO::getPalletCodeId);
        palletIds.remove(null);
        Map<Long, MesProPalletDO> pallets = palletIds.isEmpty() ? Map.of() : palletMapper.selectBatchIds(palletIds)
                .stream().collect(Collectors.toMap(MesProPalletDO::getId, p -> p));
        Map<String, MesWmProductSalesDetailDO> detailMap = details.stream().collect(Collectors.toMap(
                d -> d.getSalesId() + ":" + d.getBagCodeId() + ":" + d.getShipmentId(), d -> d, (a, b) -> a));
        // è½¬æ¢ä¸º VO
        List<MesWmTransactionRespVO> voList = pageResult.getList().stream().map(transaction -> {
            MesWmTransactionRespVO vo = BeanUtils.toBean(transaction, MesWmTransactionRespVO.class);
            MesProBagCodeDO bag = transaction.getBagCodeId() == null ? null : bagMap.get(transaction.getBagCodeId());
            if ((vo.getBagCode() == null || vo.getBagCode().isBlank()) && bag != null) vo.setBagCode(bag.getCode());
            if (Integer.valueOf(MesBizTypeConstants.WM_PRODUCT_SALES).equals(transaction.getBizType())) {
                MesWmProductSalesDetailDO detail = detailMap.get(transaction.getBizId() + ":" + transaction.getBagCodeId() + ":" + transaction.getShipmentId());
                MesProPalletDO pallet = detail == null || detail.getPalletCodeId() == null ? null : pallets.get(detail.getPalletCodeId());
                if (pallet != null) vo.setPalletCode(pallet.getCode());
            }
            // è´¨æ£€ã€ç”Ÿäº§å…¥åº“等流水没有销售明细,托盘码回退取袋码当前绑定的托盘
            if (StrUtil.isBlank(vo.getPalletCode()) && bag != null) {
                vo.setPalletCode(bag.getPalletCode());
            }
            // äº‹åŠ¡ç±»åž‹åç§°
            MesWmTransactionTypeEnum typeEnum = MesWmTransactionTypeEnum.valueOf(transaction.getType());
            vo.setTypeName(typeEnum != null ? typeEnum.getName() : "未知");
@@ -87,8 +146,22 @@
            // ç‰©æ–™ä¿¡æ¯
            MesMdmItemDO item = itemMap.get(transaction.getItemId());
            if (item != null) {
                MesMdItemDO mesItem = mesItemMap.get(item.getId());
                vo.setItemCode(item.getCode());
                vo.setItemName(item.getName());
                // è§„格:品种管理未维护时,回退取 MES ç‰©æ–™è§„格型号
                vo.setSpecification(StrUtil.blankToDefault(item.getSpecification(),
                        mesItem != null ? mesItem.getSpecification() : null));
                // ç±»åˆ«ï¼šä¼˜å…ˆå“ç§åˆ†ç±»ï¼Œå…¶æ¬¡ MES ç‰©æ–™åˆ†ç±»
                MdmItemCategoryRespDTO category = itemCategoryMap.get(item.getCategoryId());
                String itemTypeName = category != null ? category.getName() : null;
                if (StrUtil.isBlank(itemTypeName) && mesItem != null && mesItem.getItemTypeId() != null) {
                    MesMdItemTypeDO itemType = mesItemTypeMap.get(mesItem.getItemTypeId());
                    if (itemType != null) {
                        itemTypeName = itemType.getName();
                    }
                }
                vo.setItemTypeName(itemTypeName);
            }
            // ä»“库信息
            MesWmWarehouseDO warehouse = warehouseMap.get(transaction.getWarehouseId());
@@ -164,4 +237,4 @@
        }
    }
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transaction/vo/MesWmTransactionPageReqVO.java
@@ -26,8 +26,12 @@
    @Schema(description = "批次号", example = "BATCH001")
    private String batchCode;
    @Schema(description = "精确批次号")
    private String batchCodeExact;
    @Schema(description = "袋码", example = "BG202608180001")
    private String bagCode;
    private Long shipmentId;
    @Schema(description = "仓库ID", example = "1")
    private Long warehouseId;
@@ -47,6 +51,9 @@
    @Schema(description = "业务单号", example = "STOCK_ADJUST_1")
    private String bizCode;
    @Schema(description = "精确业务单号")
    private String bizCodeExact;
    @Schema(description = "库存记录ID", example = "1")
    private Long materialStockId;
@@ -58,4 +65,4 @@
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime endTime;
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/transaction/vo/MesWmTransactionRespVO.java
@@ -42,6 +42,12 @@
    @Schema(description = "物料名称", example = "螺丝")
    private String itemName;
    @Schema(description = "规格型号", example = "10mm*100mm")
    private String specification;
    @Schema(description = "物料类别名称", example = "五金配件")
    private String itemTypeName;
    @Schema(description = "变动数量(正数=入库,负数=出库)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
    private BigDecimal quantity;
@@ -57,6 +63,10 @@
    @Schema(description = "袋码", example = "BG202608180001")
    private String bagCode;
    @Schema(description = "销售出库明细记录的托盘码")
    private String palletCode;
    private Long shipmentId;
    // ========== åº“存位置 ==========
    @Schema(description = "仓库ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/warehouse/MesWmWarehouseAreaController.java
ÎļþÒÑɾ³ý
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/warehouse/MesWmWarehouseLocationController.java
ÎļþÒÑɾ³ý
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/batch/MesProBatchDO.java
@@ -16,7 +16,7 @@
 * <p>
 * æ‰¹æ¬¡ç”Ÿäº§ç®¡ç†ï¼ˆä¸€è¢‹ä¸€ç æ¨¡å—):关联产线(产线维护)与生产人员(人员配置见 {@link MesProBatchWorkerDO}),责任落实到人。
 * å“ç§å·²ä¸‹æ²‰åˆ°è¢‹ç æ•°æ®åŒ…(赋码管理),批次不再绑定品种。
 * æ‰¹æ¬¡å·è‡ªåŠ¨ç”Ÿæˆï¼Œç¼–ç è§„åˆ™åŒ…å«æ—¥æœŸã€äº§çº¿ç¼–ç ã€æµæ°´å·ç­‰è¦ç´ ï¼Œå¹¶ä¸Žè¢‹ç ï¼ˆä¸€è¢‹ä¸€ç ï¼‰ã€æ‰˜ç›˜ç è‡ªåŠ¨å…³è”ã€‚
 * æ‰¹æ¬¡å·æ”¯æŒäººå·¥è¾“入或系统生成,格式为「4 ä½å¹´ä»½ + 3 ä½æµæ°´ã€ï¼ˆå¦‚ 2026001),并与袋码(一袋一码)、托盘码自动关联。
 *
 * @author è¶…级管理员
 */
@@ -36,7 +36,7 @@
    @TableId
    private Long id;
    /**
     * æ‰¹æ¬¡å·ï¼ˆè‡ªåŠ¨ç”Ÿæˆï¼šå‰ç¼€ + æ—¥æœŸ + äº§çº¿ç¼–码 + æµæ°´å·ï¼›è¡¥å½•业务信息后生成)
     * æ‰¹æ¬¡å·ï¼ˆäººå·¥è¾“入或系统生成:4 ä½å¹´ä»½ + 3 ä½æµæ°´ï¼Œå¦‚ 2026001;补录业务信息后生成)
     */
    private String code;
    /** å†…部出入库业务码,保持稳定,不随正式码补录或绑定变化。 */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/scan/MesProScanEventDO.java
@@ -2,6 +2,7 @@
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
@@ -25,6 +26,16 @@
    private Long bagCodeId;
    private Long batchId;
    private String batchCode;
    /**
     * æ‰˜ç›˜ç¼–号(非持久化,按袋码/托盘码扫码事件实时反查,历史事件同样可展示)
     */
    @TableField(exist = false)
    private Long palletId;
    /**
     * æ‰˜ç›˜ç ï¼ˆéžæŒä¹…化,来源同上)
     */
    @TableField(exist = false)
    private String palletCode;
    private Long itemId;
    private BigDecimal quantity;
    private String deviceCode;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/seedquality/MesQcSeedIndicatorRangeDO.java
@@ -9,12 +9,25 @@
import java.math.BigDecimal;
/**
 * ç§å­è´¨æ£€é¡¹åˆæ ¼èŒƒå›´é…ç½®
 *
 * itemId ä¸ºç©ºè¡¨ç¤ºé€šç”¨é»˜è®¤é¡¹ï¼›äº§å“é…ç½®ä¼˜å…ˆäºŽé€šç”¨é»˜è®¤é¡¹ã€‚
 */
@TableName("mes_qc_seed_indicator_range")
@Data
@EqualsAndHashCode(callSuper = true)
public class MesQcSeedIndicatorRangeDO extends BaseDO {
    @TableId(type = IdType.INPUT)
    @TableId(type = IdType.AUTO)
    private Long id;
    /** äº§å“ç¼–号(mdm_item,itemType=2),为空表示通用默认项 */
    private Long itemId;
    private String indicatorCode;
    private String indicatorName;
    private String unit;
    private BigDecimal minValue;
    private BigDecimal maxValue;
    private Integer sort;
    /** çŠ¶æ€ï¼š0 å¯ç”¨ 1 åœç”¨ */
    private Integer status;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/seedquality/MesQcSeedQualityDO.java
@@ -11,6 +11,10 @@
public class MesQcSeedQualityDO extends BaseDO {
    @TableId private Long id;
    private String code;
    /** è´¨æ£€çº¿ï¼š1 åŠ å·¥éƒ¨ï¼›2 é”€å”®éƒ¨ï¼›0 æœªåŒºåˆ†ï¼ˆåŽ†å²æ•°æ®ï¼‰ */
    private Integer qcLine;
    /** ä¸šåŠ¡ç±»åž‹ï¼š1 ç”Ÿäº§å…¥åº“质检;2 ç»é”€å•†é€€è´§è´¨æ£€ */
    private Integer bizType;
    private Long itemId;
    /** ç”Ÿäº§æ‰¹æ¬¡ ID */
    private Long batchId;
@@ -19,10 +23,20 @@
    private String reason;
    /** 10 å¾…复检;20 å¾…初检审核;25 å¾…初检不合格处理;30 å¾…复检审核;40 å·²æ”¾è¡Œï¼›50 åˆæ£€é©³å›žå¾…重检;60 å¾…复检不合格处理;70 å·²æŠ¥åºŸã€‚ */
    private Integer workflowStatus;
    /** æœ¬çº¿éƒ¨é—¨å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号 */
    private String processInstanceId;
    /** å…¬å¸è´¨æ£€å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号 */
    private String companyProcessInstanceId;
    private Long qualifiedWarehouseId;
    /** ç»é”€å•†é€€è´§å•编号(bizType=2 æ—¶ä½¿ç”¨ï¼‰ */
    private Long returnSalesId;
    /** ç»é”€å•†é€€è´§å•号(bizType=2 æ—¶ä½¿ç”¨ï¼‰ */
    private String returnSalesCode;
    private Integer retestResult;
    private LocalDateTime retestedAt;
    private LocalDateTime processApprovedAt;
    private LocalDateTime lineFinishedAt;
    private LocalDateTime companySubmittedAt;
    private LocalDateTime companyApprovedAt;
    private Long inspectorId;
    private LocalDateTime inspectedAt;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/seedquality/MesQcSeedQualityIndicatorDO.java
@@ -12,6 +12,10 @@
    @TableId private Long id;
    private Long qualityId;
    private String indicatorCode;
    /** æŒ‡æ ‡åç§°å¿«ç…§ */
    private String indicatorName;
    /** æŒ‡æ ‡å•位快照 */
    private String unit;
    private BigDecimal value;
    private BigDecimal minValue;
    private BigDecimal maxValue;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesDO.java
@@ -97,6 +97,7 @@
     * æžšä¸¾ {@link MesWmProductSalesStatusEnum}
     */
    private Integer status;
    private String closeReason;
    /**
     * å¤‡æ³¨
     */
@@ -114,4 +115,4 @@
     */
    private String processDefinitionKey;
}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesDetailDO.java
@@ -46,6 +46,8 @@
     * å…³è” {@link MesWmProductSalesDO#getId()}
     */
    private Long salesId;
    /** å·²åˆ†é…çš„发货记录,空值表示本轮待拣货或历史明细。 */
    private Long shipmentId;
    /**
     * ç‰©æ–™ID
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesShipmentDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/** é”€å”®å‡ºåº“单的单次发货记录。 */
@TableName("mes_wm_product_sales_shipment")
@KeySequence("mes_wm_product_sales_shipment_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class MesWmProductSalesShipmentDO extends BaseDO {
    @TableId
    private Long id;
    private Long salesId;
    private Integer status;
    private String carrier;
    private String shippingNumber;
    private LocalDateTime outboundTime;
    private Long outboundUserId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/transaction/MesWmTransactionDO.java
@@ -111,6 +111,7 @@
    private Long bagCodeId;
    /** è¢‹ç  */
    private String bagCode;
    private Long shipmentId;
    // ==================== åº“存位置 ====================
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java
@@ -37,6 +37,12 @@
                .and(q -> q.eq(MesProBagCodeDO::getBusinessCode, code).or().eq(MesProBagCodeDO::getCode, code)));
    }
    /** æ‰¹é‡æŒ‰æ‰«ç å†…容反查袋码(兼容业务码、追溯码),用于扫码事件展示所属托盘码 */
    default List<MesProBagCodeDO> selectListByScanCodes(Collection<String> codes) {
        return selectList(new LambdaQueryWrapperX<MesProBagCodeDO>().and(q ->
                q.in(MesProBagCodeDO::getBusinessCode, codes).or().in(MesProBagCodeDO::getCode, codes)));
    }
    default MesProBagCodeDO selectByScanCodeForUpdate(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .and(q -> q.eq(MesProBagCodeDO::getBusinessCode, code).or().eq(MesProBagCodeDO::getCode, code))
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/batch/MesProBatchMapper.java
@@ -20,6 +20,21 @@
        return selectOne(MesProBatchDO::getCode, code);
    }
    /**
     * æŒ‰æ‰¹æ¬¡å·æŸ¥è¯¢ï¼ˆåŒ…含已逻辑删除记录)
     * <p>
     * æ‰¹æ¬¡å·å­˜åœ¨å”¯ä¸€ç´¢å¼•,逻辑删除的记录仍占用码值,校验重复时必须一并纳入。
     */
    @Select("SELECT * FROM mes_pro_batch WHERE code = #{code} LIMIT 1")
    MesProBatchDO selectByCodeIgnoreDeleted(@org.apache.ibatis.annotations.Param("code") String code);
    /**
     * æŸ¥è¯¢æŒ‡å®šå¹´ä»½å·²ä½¿ç”¨çš„æœ€å¤§æ‰¹æ¬¡æµæ°´å·ï¼ˆæ‰¹æ¬¡å·æ ¼å¼ä¸º 4 ä½å¹´ä»½ + 3 ä½æµæ°´ï¼‰
     */
    @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(code, 5) AS UNSIGNED)), 0) FROM mes_pro_batch "
            + "WHERE code LIKE CONCAT(#{year}, '%') AND code REGEXP '^[0-9]{7}$'")
    Integer selectMaxCodeSerialByYear(@org.apache.ibatis.annotations.Param("year") String year);
    default MesProBatchDO selectByScanCode(String code) {
        return selectOne(new LambdaQueryWrapperX<MesProBatchDO>().and(q ->
                q.eq(MesProBatchDO::getBusinessCode, code).or().eq(MesProBatchDO::getCode, code)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/pallet/MesProPalletMapper.java
@@ -13,6 +13,7 @@
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
/**
@@ -50,6 +51,22 @@
        return selectList(MesProPalletDO::getBatchId, batchId);
    }
    /** æ‰¹é‡æŒ‰æ‰«ç å†…容反查托盘(兼容业务码、正式托盘码、临时托盘码),用于扫码事件展示托盘码 */
    default List<MesProPalletDO> selectListByScanCodes(Collection<String> codes) {
        return selectList(new LambdaQueryWrapperX<MesProPalletDO>().and(q ->
                q.in(MesProPalletDO::getBusinessCode, codes).or().in(MesProPalletDO::getCode, codes)
                        .or().in(MesProPalletDO::getTempCode, codes)));
    }
    /**
     * æ‰¹é‡æŸ¥è¯¢å¤šä¸ªæ‰¹æ¬¡ä¸‹çš„æ‰˜ç›˜ç ï¼ˆæŒ‰æ‰˜ç›˜åºå·å‡åºï¼‰
     */
    default List<MesProPalletDO> selectListByBatchIds(Collection<Long> batchIds) {
        return selectList(new LambdaQueryWrapperX<MesProPalletDO>()
                .in(MesProPalletDO::getBatchId, batchIds)
                .orderByAsc(MesProPalletDO::getPalletNo));
    }
    /**
     * åˆ†é¡µæŸ¥è¯¢æœªç»‘定生产批次的临时托盘(无正式托盘码且未作废),供批次新增/补录绑定托盘下拉使用。
     * <p>
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedIndicatorRangeMapper.java
@@ -1,9 +1,58 @@
package cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedIndicatorRangeDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface MesQcSeedIndicatorRangeMapper extends BaseMapperX<MesQcSeedIndicatorRangeDO> {
    default List<MesQcSeedIndicatorRangeDO> selectListByItemId(Long itemId, String keyword, Integer status) {
        LambdaQueryWrapperX<MesQcSeedIndicatorRangeDO> wrapper = new LambdaQueryWrapperX<>();
        if (itemId == null) {
            wrapper.isNull(MesQcSeedIndicatorRangeDO::getItemId);
        } else {
            wrapper.eq(MesQcSeedIndicatorRangeDO::getItemId, itemId);
        }
        wrapper.and(StrUtil.isNotBlank(keyword), w -> w
                .like(MesQcSeedIndicatorRangeDO::getIndicatorName, keyword)
                .or().like(MesQcSeedIndicatorRangeDO::getIndicatorCode, keyword));
        wrapper.eqIfPresent(MesQcSeedIndicatorRangeDO::getStatus, status)
                .orderByAsc(MesQcSeedIndicatorRangeDO::getSort)
                .orderByAsc(MesQcSeedIndicatorRangeDO::getId);
        return selectList(wrapper);
    }
    /** äº§å“ä¸“属且启用的指标,按排序返回 */
    default List<MesQcSeedIndicatorRangeDO> selectEnabledByItemId(Long itemId) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedIndicatorRangeDO>()
                .eq(MesQcSeedIndicatorRangeDO::getItemId, itemId)
                .eq(MesQcSeedIndicatorRangeDO::getStatus, 0)
                .orderByAsc(MesQcSeedIndicatorRangeDO::getSort)
                .orderByAsc(MesQcSeedIndicatorRangeDO::getId));
    }
    /** æœªç»‘定产品的通用默认指标,按排序返回 */
    default List<MesQcSeedIndicatorRangeDO> selectEnabledDefaults() {
        return selectList(new LambdaQueryWrapperX<MesQcSeedIndicatorRangeDO>()
                .isNull(MesQcSeedIndicatorRangeDO::getItemId)
                .eq(MesQcSeedIndicatorRangeDO::getStatus, 0)
                .orderByAsc(MesQcSeedIndicatorRangeDO::getSort)
                .orderByAsc(MesQcSeedIndicatorRangeDO::getId));
    }
    default MesQcSeedIndicatorRangeDO selectByItemIdAndCode(Long itemId, String indicatorCode) {
        LambdaQueryWrapperX<MesQcSeedIndicatorRangeDO> wrapper = new LambdaQueryWrapperX<>();
        if (itemId == null) {
            wrapper.isNull(MesQcSeedIndicatorRangeDO::getItemId);
        } else {
            wrapper.eq(MesQcSeedIndicatorRangeDO::getItemId, itemId);
        }
        wrapper.eq(MesQcSeedIndicatorRangeDO::getIndicatorCode, indicatorCode);
        return selectOne(wrapper);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityMapper.java
@@ -1,13 +1,17 @@
package cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityPageReqVO;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityDO;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Mapper
public interface MesQcSeedQualityMapper extends BaseMapperX<MesQcSeedQualityDO> {
@@ -16,29 +20,79 @@
        return selectPage(reqVO, new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .likeIfPresent(MesQcSeedQualityDO::getCode, reqVO.getCode())
                .likeIfPresent(MesQcSeedQualityDO::getBatchCode, reqVO.getBatchCode())
                .eqIfPresent(MesQcSeedQualityDO::getBatchCode, reqVO.getExactBatchCode())
                .eqIfPresent(MesQcSeedQualityDO::getResult, reqVO.getResult())
                .eqIfPresent(MesQcSeedQualityDO::getWorkflowStatus, reqVO.getWorkflowStatus())
                .eqIfPresent(MesQcSeedQualityDO::getQcLine, reqVO.getQcLine())
                .eqIfPresent(MesQcSeedQualityDO::getBizType, reqVO.getBizType())
                .isNull(Boolean.TRUE.equals(reqVO.getUnbound()), MesQcSeedQualityDO::getBatchCode)
                .orderByAsc(MesQcSeedQualityDO::getCode)
                .orderByAsc(MesQcSeedQualityDO::getQcLine)
                .orderByDesc(MesQcSeedQualityDO::getInspectedAt)
                .orderByDesc(MesQcSeedQualityDO::getId));
    }
    default MesQcSeedQualityDO selectByCode(String code) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>().eq(MesQcSeedQualityDO::getCode, code));
    /** åŒä¸€è¢‹ç å¯èƒ½åŒæ—¶å­˜åœ¨åŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•ï¼ŒæŒ‰è´¨æ£€çº¿å‡åºè¿”å›ž */
    default List<MesQcSeedQualityDO> selectListByCode(String code) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getCode, code)
                .orderByAsc(MesQcSeedQualityDO::getQcLine));
    }
    default List<MesQcSeedQualityDO> selectListByCodes(java.util.Collection<String> codes) {
    default List<MesQcSeedQualityDO> selectListByCodeForUpdate(String code) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getCode, code)
                .orderByAsc(MesQcSeedQualityDO::getQcLine)
                .last("FOR UPDATE"));
    }
    default MesQcSeedQualityDO selectByCodeAndLine(String code, Integer qcLine) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getCode, code)
                .eq(MesQcSeedQualityDO::getQcLine, qcLine));
    }
    default MesQcSeedQualityDO selectByReturnSalesId(Long returnSalesId) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getReturnSalesId, returnSalesId)
                .orderByDesc(MesQcSeedQualityDO::getId));
    }
    default MesQcSeedQualityDO selectByReturnSalesIdForUpdate(Long returnSalesId) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getReturnSalesId, returnSalesId)
                .orderByDesc(MesQcSeedQualityDO::getId)
                .last("FOR UPDATE"));
    }
    /** æ¸…空同一袋码的公司质检流程实例编号,便于公司审批驳回后重新发起 */
    default void clearCompanyProcessInstance(String code) {
        update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getCode, code)
                .set(MesQcSeedQualityDO::getCompanyProcessInstanceId, null));
    }
    default void clearLineProcessInstance(Long id) {
        update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getId, id)
                .set(MesQcSeedQualityDO::getProcessInstanceId, null));
    }
    default List<MesQcSeedQualityDO> selectListByCodes(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) {
    default List<MesQcSeedQualityDO> selectListByCodesForUpdate(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 MesQcSeedQualityDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>().eq(MesQcSeedQualityDO::getId, id).last("FOR UPDATE"));
    }
    default List<MesQcSeedQualityDO> selectListByIdsForUpdate(java.util.Collection<Long> ids) {
    default List<MesQcSeedQualityDO> selectListByIdsForUpdate(Collection<Long> ids) {
        if (ids.isEmpty()) return List.of();
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .in(MesQcSeedQualityDO::getId, ids)
@@ -46,9 +100,79 @@
                .last("FOR UPDATE"));
    }
    default List<MesQcSeedQualityDO> selectListByProcessInstanceId(String processInstanceId) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getProcessInstanceId, processInstanceId));
    }
    default List<MesQcSeedQualityDO> selectListByCompanyProcessInstanceId(String processInstanceId) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getCompanyProcessInstanceId, processInstanceId));
    }
    default List<MesQcSeedQualityDO> selectListByBatchCode(String batchCode) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .eq(MesQcSeedQualityDO::getBatchCode, batchCode)
                .orderByDesc(MesQcSeedQualityDO::getInspectedAt));
    }
    /** æŒ‰ç”Ÿäº§æ‰¹æ¬¡åˆ†ç»„的组数(未绑定批次归入空批次组) */
    default long countBatchGroups(MesQcSeedQualityPageReqVO reqVO) {
        QueryWrapper<MesQcSeedQualityDO> wrapper = buildBatchGroupFilter(reqVO);
        wrapper.select("COUNT(DISTINCT IFNULL(batch_code, '')) AS cnt");
        List<Object> values = selectObjs(wrapper);
        if (values.isEmpty() || values.get(0) == null) {
            return 0L;
        }
        return ((Number) values.get(0)).longValue();
    }
    /**
     * æŒ‰ç”Ÿäº§æ‰¹æ¬¡åˆ†ç»„的分页汇总,offset/limit ç”±è°ƒç”¨æ–¹æŒ‰æ‰¹æ¬¡é¡µè®¡ç®—。
     * åŒä¸€è¢‹ç å­˜åœ¨åŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•ï¼Œå› æ­¤æŒ‰è¢‹ç åŽ»é‡ç»Ÿè®¡ã€‚
     */
    default List<Map<String, Object>> selectBatchGroups(MesQcSeedQualityPageReqVO reqVO, long offset, int limit) {
        QueryWrapper<MesQcSeedQualityDO> wrapper = buildBatchGroupFilter(reqVO);
        wrapper.select(
                "IFNULL(batch_code, '') AS batchCode",
                "MAX(batch_id) AS batchId",
                "MAX(item_id) AS itemId",
                "COUNT(DISTINCT code) AS totalCount",
                "COUNT(DISTINCT CASE WHEN result = 1 THEN code END) AS passCount",
                "COUNT(DISTINCT CASE WHEN result = 2 THEN code END) AS failCount",
                "COUNT(DISTINCT CASE WHEN result IS NULL OR result = 0 THEN code END) AS processingCount",
                "COUNT(DISTINCT CASE WHEN workflow_status IN (10, 50) THEN code END) AS pendingRetestCount",
                "COUNT(DISTINCT CASE WHEN workflow_status IN (20, 25) THEN code END) AS pendingProcessReviewCount",
                "COUNT(DISTINCT CASE WHEN workflow_status IN (30, 60) THEN code END) AS pendingCompanyReviewCount",
                "COUNT(DISTINCT CASE WHEN workflow_status = 40 THEN code END) AS releasedCount",
                "COUNT(DISTINCT CASE WHEN workflow_status = 70 THEN code END) AS scrappedCount",
                "MAX(inspected_at) AS lastInspectedAt");
        wrapper.groupBy("IFNULL(batch_code, '')");
        wrapper.orderByDesc("MAX(inspected_at)", "MAX(id)");
        wrapper.last("LIMIT " + offset + ", " + limit);
        return selectMaps(wrapper);
    }
    private static QueryWrapper<MesQcSeedQualityDO> buildBatchGroupFilter(MesQcSeedQualityPageReqVO reqVO) {
        QueryWrapper<MesQcSeedQualityDO> wrapper = new QueryWrapper<>();
        if (StrUtil.isNotBlank(reqVO.getCode())) {
            wrapper.like("code", reqVO.getCode());
        }
        if (StrUtil.isNotBlank(reqVO.getBatchCode())) {
            wrapper.like("batch_code", reqVO.getBatchCode());
        }
        if (reqVO.getResult() != null) {
            wrapper.eq("result", reqVO.getResult());
        }
        if (reqVO.getWorkflowStatus() != null) {
            wrapper.eq("workflow_status", reqVO.getWorkflowStatus());
        }
        if (reqVO.getQcLine() != null) {
            wrapper.eq("qc_line", reqVO.getQcLine());
        }
        if (reqVO.getBizType() != null) {
            wrapper.eq("biz_type", reqVO.getBizType());
        }
        return wrapper;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstock/MesWmMaterialStockMapper.java
@@ -37,8 +37,10 @@
                .eqIfPresent(MesWmMaterialStockDO::getVendorId, reqVO.getVendorId())
                .eqIfPresent(MesWmMaterialStockDO::getStockState, reqVO.getStockState())
                .eqIfPresent(MesWmMaterialStockDO::getFrozen, reqVO.getFrozen());
        wrapper.ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO)
                .orderByAsc(MesWmMaterialStockDO::getReceiptTime);
        if (!Boolean.TRUE.equals(reqVO.getIncludeZero())) {
            wrapper.ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO);
        }
        wrapper.orderByAsc(MesWmMaterialStockDO::getReceiptTime);
        // è™šæ‹Ÿä»“过滤(Service å±‚已将 virtualFilter è§£æžä¸º virtualWarehouseId)
        if (virtualWarehouseId != null) {
            if (MesWmMaterialStockPageReqVO.VIRTUAL_FILTER_ONLY.equals(reqVO.getVirtualFilter())) {
@@ -162,8 +164,8 @@
        LambdaQueryWrapperX<MesWmMaterialStockDO> wrapper = new LambdaQueryWrapperX<MesWmMaterialStockDO>()
                .eq(MesWmMaterialStockDO::getItemId, itemId)
                .eq(MesWmMaterialStockDO::getWarehouseId, warehouseId)
                .eq(MesWmMaterialStockDO::getLocationId, locationId)
                .eq(MesWmMaterialStockDO::getAreaId, areaId)
                .eqIfPresent(MesWmMaterialStockDO::getLocationId, locationId)
                .eqIfPresent(MesWmMaterialStockDO::getAreaId, areaId)
                .eq(MesWmMaterialStockDO::getStockState, MesWmStockStateEnum.QUALIFIED.getState());
        // batchId=null æ—¶ç²¾ç¡®åŒ¹é… is null
        if (batchId != null) {
@@ -186,8 +188,8 @@
        LambdaQueryWrapperX<MesWmMaterialStockDO> wrapper = new LambdaQueryWrapperX<MesWmMaterialStockDO>()
                .eq(MesWmMaterialStockDO::getItemId, itemId)
                .eq(MesWmMaterialStockDO::getWarehouseId, warehouseId)
                .eq(MesWmMaterialStockDO::getLocationId, locationId)
                .eq(MesWmMaterialStockDO::getAreaId, areaId)
                .eqIfPresent(MesWmMaterialStockDO::getLocationId, locationId)
                .eqIfPresent(MesWmMaterialStockDO::getAreaId, areaId)
                .eq(MesWmMaterialStockDO::getStockState, MesWmStockStateEnum.QUALIFIED.getState());
        // batchId=null æ—¶ç²¾ç¡®åŒ¹é… is null
        if (batchId != null) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/materialstockbag/MesWmMaterialStockBagMapper.java
@@ -25,7 +25,7 @@
        return selectPage(reqVO, new LambdaQueryWrapperX<MesWmMaterialStockBagDO>()
                .eq(MesWmMaterialStockBagDO::getMaterialStockId, reqVO.getMaterialStockId())
                .likeIfPresent(MesWmMaterialStockBagDO::getBagCode, reqVO.getBagCode())
                .eq(MesWmMaterialStockBagDO::getStatus, 1)
                .eqIfPresent(MesWmMaterialStockBagDO::getStatus, Boolean.TRUE.equals(reqVO.getIncludeHistory()) ? null : 1)
                .orderByAsc(MesWmMaterialStockBagDO::getInboundTime));
    }
@@ -81,6 +81,12 @@
                .orderByAsc(MesWmMaterialStockBagDO::getInboundTime));
    }
    default List<MesWmMaterialStockBagDO> selectHistoryByMaterialStockIds(Collection<Long> materialStockIds) {
        if (materialStockIds.isEmpty()) return List.of();
        return selectList(new LambdaQueryWrapperX<MesWmMaterialStockBagDO>()
                .in(MesWmMaterialStockBagDO::getMaterialStockId, materialStockIds));
    }
    default List<MesWmMaterialStockBagDO> selectListByMaterialStockId(Long materialStockId) {
        return selectList(new LambdaQueryWrapperX<MesWmMaterialStockBagDO>()
                .eq(MesWmMaterialStockBagDO::getMaterialStockId, materialStockId)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesDetailMapper.java
@@ -5,6 +5,7 @@
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Collection;
/**
 * MES é”€å”®å‡ºåº“明细 Mapper
@@ -14,11 +15,26 @@
@Mapper
public interface MesWmProductSalesDetailMapper extends BaseMapperX<MesWmProductSalesDetailDO> {
    default List<MesWmProductSalesDetailDO> selectBySalesIdsAndBagIds(Collection<Long> salesIds, Collection<Long> bagIds) {
        if (salesIds.isEmpty() || bagIds.isEmpty()) return List.of();
        return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesWmProductSalesDetailDO>()
                .in(MesWmProductSalesDetailDO::getSalesId, salesIds)
                .in(MesWmProductSalesDetailDO::getBagCodeId, bagIds));
    }
    default List<MesWmProductSalesDetailDO> selectByBagIds(Collection<Long> bagIds) {
        if (bagIds.isEmpty()) return List.of();
        return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesWmProductSalesDetailDO>()
                .in(MesWmProductSalesDetailDO::getBagCodeId, bagIds));
    }
    // ä»…未取消且尚未完成的出库单占用袋码。旧整托明细也参与重叠判断。
    @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
        LEFT JOIN mes_wm_product_sales_shipment sh ON sh.id = d.shipment_id AND sh.deleted = 0
        WHERE d.deleted = 0 AND s.deleted = 0 AND s.status IN (2, 10, 3)
          AND (d.shipment_id IS NULL OR sh.status IN (10, 20))
          AND d.batch_code = #{batchCode}
        """)
    List<MesWmProductSalesDetailDO> selectPickingByBatchCode(String batchCode);
@@ -26,7 +42,9 @@
    @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
        LEFT JOIN mes_wm_product_sales_shipment sh ON sh.id = d.shipment_id AND sh.deleted = 0
        WHERE d.deleted = 0 AND s.deleted = 0 AND s.status IN (2, 10, 3)
          AND (d.shipment_id IS NULL OR sh.status IN (10, 20))
          AND d.batch_code = #{batchCode} FOR UPDATE
        """)
    List<MesWmProductSalesDetailDO> selectPickingByBatchCodeForUpdate(String batchCode);
@@ -35,6 +53,12 @@
        return selectList(MesWmProductSalesDetailDO::getLineId, lineId);
    }
    default void unassignShipment(Long shipmentId) {
        update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<MesWmProductSalesDetailDO>()
                .eq(MesWmProductSalesDetailDO::getShipmentId, shipmentId)
                .set(MesWmProductSalesDetailDO::getShipmentId, null));
    }
    default List<MesWmProductSalesDetailDO> selectListBySalesId(Long salesId) {
        return selectList(MesWmProductSalesDetailDO::getSalesId, salesId);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesShipmentMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Collection;
@Mapper
public interface MesWmProductSalesShipmentMapper extends BaseMapperX<MesWmProductSalesShipmentDO> {
    default List<MesWmProductSalesShipmentDO> selectBySalesId(Long salesId) {
        return selectList(new LambdaQueryWrapperX<MesWmProductSalesShipmentDO>()
                .eq(MesWmProductSalesShipmentDO::getSalesId, salesId)
                .orderByAsc(MesWmProductSalesShipmentDO::getId));
    }
    default List<MesWmProductSalesShipmentDO> selectCompletedBySalesIds(Collection<Long> salesIds) {
        if (salesIds.isEmpty()) return List.of();
        return selectList(new LambdaQueryWrapperX<MesWmProductSalesShipmentDO>()
                .in(MesWmProductSalesShipmentDO::getSalesId, salesIds).eq(MesWmProductSalesShipmentDO::getStatus, 30));
    }
    default MesWmProductSalesShipmentDO selectActive(Long salesId) {
        return selectOne(new LambdaQueryWrapperX<MesWmProductSalesShipmentDO>()
                .eq(MesWmProductSalesShipmentDO::getSalesId, salesId)
                .in(MesWmProductSalesShipmentDO::getStatus, 10, 20)
                .orderByDesc(MesWmProductSalesShipmentDO::getId).last("LIMIT 1"));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/transaction/MesWmTransactionMapper.java
@@ -13,17 +13,23 @@
@Mapper
public interface MesWmTransactionMapper extends BaseMapperX<MesWmTransactionDO> {
    @org.apache.ibatis.annotations.Select("SELECT COALESCE(-SUM(quantity), 0) FROM mes_wm_transaction WHERE biz_type = 119 AND type = 2 AND biz_line_id = #{lineId} AND deleted = 0")
    java.math.BigDecimal sumSalesOutboundByLineId(Long lineId);
    default PageResult<MesWmTransactionDO> selectPage(MesWmTransactionPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<MesWmTransactionDO>()
                .eqIfPresent(MesWmTransactionDO::getItemId, reqVO.getItemId())
                .likeIfPresent(MesWmTransactionDO::getBatchCode, reqVO.getBatchCode())
                .eqIfPresent(MesWmTransactionDO::getBatchCode, reqVO.getBatchCodeExact())
                .likeIfPresent(MesWmTransactionDO::getBagCode, reqVO.getBagCode())
                .eqIfPresent(MesWmTransactionDO::getShipmentId, reqVO.getShipmentId())
                .eqIfPresent(MesWmTransactionDO::getWarehouseId, reqVO.getWarehouseId())
                .eqIfPresent(MesWmTransactionDO::getLocationId, reqVO.getLocationId())
                .eqIfPresent(MesWmTransactionDO::getAreaId, reqVO.getAreaId())
                .eqIfPresent(MesWmTransactionDO::getType, reqVO.getType())
                .eqIfPresent(MesWmTransactionDO::getBizType, reqVO.getBizType())
                .likeIfPresent(MesWmTransactionDO::getBizCode, reqVO.getBizCode())
                .eqIfPresent(MesWmTransactionDO::getBizCode, reqVO.getBizCodeExact())
                .eqIfPresent(MesWmTransactionDO::getMaterialStockId, reqVO.getMaterialStockId())
                .geIfPresent(MesWmTransactionDO::getTransactionTime, reqVO.getStartTime())
                .leIfPresent(MesWmTransactionDO::getTransactionTime, reqVO.getEndTime())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/MesCodeRedisDAO.java
@@ -25,6 +25,11 @@
    private static final String BAG_NO_SEQ_KEY = "mes:bag_no:seq";
    /**
     * ç”Ÿäº§æ‰¹æ¬¡æµæ°´å·è‡ªå¢ž KEY å‰ç¼€ï¼ˆæŒ‰å¹´ä»½éš”离)
     */
    private static final String BATCH_NO_SEQ_KEY_PREFIX = "mes:pro_batch_code:seq:";
    /**
     * å•次批量取码的最大个数
     * <p>
     * å— Redis å•条命令参数规模与响应体大小约束,过大反而增加单次阻塞时间。
@@ -60,6 +65,17 @@
                    + "local base = tonumber(ARGV[2]) "
                    + "if (not cur) or (tonumber(cur) < base) then redis.call('SET', KEYS[1], base) end "
                    + "return redis.call('INCRBY', KEYS[1], ARGV[1])",
            Long.class);
    /**
     * åŽŸå­åˆ†é…ç”Ÿäº§æ‰¹æ¬¡æµæ°´å·ï¼šå…ˆæŒ‰åº“å†…å½“å‰æœ€å¤§æµæ°´æ ¡æ­£åŸºå‡†å€¼ï¼Œå†è‡ªå¢žã€‚
     * æ‰‹åŠ¨å½•å…¥è¾ƒå¤§æ‰¹æ¬¡å·æˆ–ç›´æŽ¥å¯¼å…¥æ•°æ®åŽï¼Œä¸‹ä¸€æ¬¡è‡ªåŠ¨å–å·ä¸ä¼šä¸Žä¹‹é‡å¤ã€‚
     */
    private static final RedisScript<Long> ALLOCATE_BATCH_NO_SCRIPT = new DefaultRedisScript<>(
            "local cur = redis.call('GET', KEYS[1]) "
                    + "local base = tonumber(ARGV[1]) "
                    + "if (not cur) or (tonumber(cur) < base) then redis.call('SET', KEYS[1], base) end "
                    + "return redis.call('INCR', KEYS[1])",
            Long.class);
    @Resource
@@ -117,6 +133,23 @@
    }
    /**
     * åŽŸå­åˆ†é…ç”Ÿäº§æ‰¹æ¬¡æµæ°´å·ï¼šå…ˆæŒ‰åº“å†…å½“å‰æœ€å¤§æµæ°´æ ¡æ­£åŸºå‡†å€¼ï¼Œå†è‡ªå¢žã€‚
     *
     * @param year         æ‰¹æ¬¡å·å¹´ä»½ï¼ˆ4 ä½ï¼‰
     * @param dbMaxSerial  æ•°æ®åº“当年已用最大流水号,传 null è§†ä¸º 0
     * @return ä¸‹ä¸€ä¸ªæµæ°´å·
     */
    public int allocateBatchNo(String year, Integer dbMaxSerial) {
        Long next = stringRedisTemplate.execute(ALLOCATE_BATCH_NO_SCRIPT,
                List.of(BATCH_NO_SEQ_KEY_PREFIX + year),
                String.valueOf(dbMaxSerial == null ? 0 : dbMaxSerial));
        if (next == null) {
            throw new IllegalStateException("分配生产批次流水号失败,请重试");
        }
        return next.intValue();
    }
    /**
     * åŽŸå­åˆ†é…ä¸€æ®µè¿žç»­çš„è¢‹åºå·ï¼Œè¿”å›žåŒºé—´èµ·å§‹å€¼ã€‚
     * <p>
     * è¢‹åºå·åŽŸå…ˆç”±ã€ŒæŸ¥åº“å†…æœ€å¤§å€¼å† +1」得到,并发生成时两个请求会读到同一个最大值,
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -861,6 +861,9 @@
    ErrorCode PRO_QR_EXPORT_TOO_MANY_FOR_SYNC = new ErrorCode(1_040_750_015, "本次需导出 {} å¼ ï¼Œè¶…过同步导出上限 {} å¼ ï¼Œè¯·æ”¹ç”¨åŽå°å¯¼å‡ºä»»åŠ¡");
    ErrorCode PRO_QR_EXPORT_TASK_FILE_MISSING = new ErrorCode(1_040_750_016, "导出文件不存在或已被删除,请重新导出");
    ErrorCode PRO_QR_EXPORT_TASK_STATUS_INVALID = new ErrorCode(1_040_750_017, "当前导出任务状态不允许该操作");
    ErrorCode PRO_BATCH_CODE_INVALID = new ErrorCode(1_040_750_018, "生产批次号格式不正确,应为年份+3位流水号,如 2026001");
    ErrorCode PRO_BATCH_CODE_DUPLICATE = new ErrorCode(1_040_750_019, "生产批次号【{}】已存在");
    ErrorCode PRO_BATCH_CODE_EXHAUSTED = new ErrorCode(1_040_750_025, "本年度生产批次号已用尽(001-999)");
    // ========== MES ç”Ÿäº§ç®¡ç†-袋码赋码(1-040-752-000) ==========
    ErrorCode PRO_BAG_CODE_NOT_EXISTS = new ErrorCode(1_040_752_000, "袋码不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/wm/MesWmProductSalesStatusEnum.java
@@ -59,7 +59,8 @@
     *
     * å¯¹åº” MesWmProductSalesService#cancelProductSales æ–¹æ³•
     */
    CANCELED(MesOrderStatusConstants.CANCELLED, "已取消");
    CANCELED(MesOrderStatusConstants.CANCELLED, "已取消"),
    CLOSED(MesOrderStatusConstants.CLOSED, "已关闭");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(MesWmProductSalesStatusEnum::getStatus).toArray(Integer[]::new);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/workstation/MesMdWorkstationServiceImpl.java
@@ -9,14 +9,10 @@
import cn.iocoder.yudao.module.mes.controller.admin.md.workstation.vo.MesMdWorkstationSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkshopDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.workstation.MesMdWorkstationMapper;
import cn.iocoder.yudao.module.mes.enums.wm.BarcodeBizTypeEnum;
import cn.iocoder.yudao.module.mes.service.wm.barcode.MesWmBarcodeService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService;
import jakarta.annotation.Resource;
@@ -55,10 +51,6 @@
    private MesMdWorkshopService workshopService;
    @Resource
    private MesWmWarehouseService warehouseService;
    @Resource
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource
    private MesWmBarcodeService barcodeService;
    @Resource
@@ -144,68 +136,18 @@
    }
    /**
     * æ ¡éªŒå¹¶å¤„理仓库层级关系
     *
     * ä»“库层级结构:仓库 -> åº“区 -> åº“位
     * å¤„理规则:
     * 1. å¦‚果仓库/库区/库位都未填写,则自动设置虚拟线边库
     * 2. å¦‚果填写了库区,必须填写仓库,且库区必须属于该仓库
     * 3. å¦‚果填写了库位,必须填写库区,且库位必须属于该库区
     * å¤„理仓库:未填写仓库时自动设置虚拟线边仓
     *
     * @param reqVO å·¥ä½œç«™ä¿å­˜è¯·æ±‚对象
     */
    private void handleWarehouseHierarchy(MesMdWorkstationSaveReqVO reqVO) {
        // 1.1 èŽ·å–ä»“åº“ã€åº“åŒºã€åº“ä½ ID
        Long warehouseId = reqVO.getWarehouseId();
        Long locationId = reqVO.getLocationId();
        Long areaId = reqVO.getAreaId();
        // 1.2 å¦‚果都为空,则自动设置虚拟线边库
        if (warehouseId == null && locationId == null && areaId == null) {
        if (warehouseId == null) {
            MesWmWarehouseDO warehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
            MesWmWarehouseLocationDO location = locationService.getWarehouseLocationByCode(
                    MesWmWarehouseLocationDO.WIP_VIRTUAL_LOCATION);
            MesWmWarehouseAreaDO area = areaService.getWarehouseAreaByCode(
                    MesWmWarehouseAreaDO.WIP_VIRTUAL_AREA);
            reqVO.setWarehouseId(warehouse.getId());
            reqVO.setLocationId(location.getId());
            reqVO.setAreaId(area.getId());
            return;
        }
        // 2. æ ¡éªŒä»“库是否存在
        if (warehouseId != null) {
            warehouseService.validateWarehouseExists(warehouseId);
        }
        // 3. æ ¡éªŒåº“区:如果填写了库区,必须填写仓库,且库区必须属于该仓库
        MesWmWarehouseLocationDO location;
        if (locationId != null) {
            // 3.1 æ ¡éªŒåº“区是否存在
            location = locationService.validateWarehouseLocationExists(locationId);
            // 3.2 æ ¡éªŒå¿…须填写仓库
            if (warehouseId == null) {
                throw exception(WM_WAREHOUSE_REQUIRED);
            }
            // 3.3 æ ¡éªŒåº“区是否属于该仓库
            if (ObjUtil.notEqual(location.getWarehouseId(), warehouseId)) {
                throw exception(WM_WAREHOUSE_LOCATION_RELATION_INVALID);
            }
        }
        // 4. æ ¡éªŒåº“位:如果填写了库位,必须填写库区,且库位必须属于该库区
        if (areaId == null) {
            return;
        }
        // 4.1 æ ¡éªŒåº“位是否存在
        MesWmWarehouseAreaDO area = areaService.validateWarehouseAreaExists(areaId);
        // 4.2 æ ¡éªŒå¿…须填写库区
        if (locationId == null) {
            throw exception(WM_WAREHOUSE_LOCATION_REQUIRED);
        }
        // 4.3 æ ¡éªŒåº“位是否属于该库区
        if (ObjUtil.notEqual(area.getLocationId(), locationId)) {
            throw exception(WM_WAREHOUSE_AREA_RELATION_INVALID);
        }
        warehouseService.validateWarehouseExists(warehouseId);
    }
    private void validateWorkstationCodeUnique(Long id, String code) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/batch/MesProBatchServiceImpl.java
@@ -20,9 +20,7 @@
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchWorkerMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBatchStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.pro.line.MesProLineService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
@@ -35,12 +33,14 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@@ -55,6 +55,12 @@
@Validated
@Slf4j
public class MesProBatchServiceImpl implements MesProBatchService {
    /**
     * æ‰¹æ¬¡å·æ ¼å¼ï¼š4 ä½å¹´ä»½ + 3 ä½æµæ°´ï¼ˆå¦‚ 2026001)
     */
    private static final Pattern BATCH_CODE_PATTERN = Pattern.compile("^[0-9]{7}$");
    private static final int BATCH_CODE_MAX_SERIAL = 999;
    @Resource
    private MesProBatchMapper batchMapper;
@@ -78,8 +84,6 @@
    private MesCodeRedisDAO codeRedisDao;
    @Resource
    private MesMdAutoCodeRecordService autoCodeRecordService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private MesProLineService lineService;
@@ -94,8 +98,8 @@
        validateRelations(createReqVO);
        createReqVO.setItemId(resolveBindingItem(null, createReqVO));
        // 2. ç”Ÿæˆæ‰¹æ¬¡å·ï¼ˆç¼–码规则:前缀 + æ—¥æœŸ + äº§çº¿ç¼–码 + æµæ°´å·ï¼‰
        String code = generateBatchCode(line.getCode());
        // 2. ç¡®å®šæ‰¹æ¬¡å·ï¼ˆäººå·¥è¾“入优先,未输入则按「年份+3位流水」自动生成)
        String code = resolveBatchCode(createReqVO.getCode(), null);
        // 3. æ’入批次(产线编码/名称冗余回填;勾选绑定托盘时批次直接进入生产中)
        boolean hasBindings = hasBindings(createReqVO);
@@ -152,11 +156,11 @@
        if (StrUtil.isNotEmpty(batch.getCode())) {
            throw exception(PRO_BATCH_ALREADY_FILLED);
        }
        // 2. æ ¡éªŒå…³è”数据并生成正式批次号
        // 2. æ ¡éªŒå…³è”数据并确定正式批次号(人工输入优先,未输入则自动生成)
        MesProLineDO line = lineService.validateLineExistsAndEnable(fillReqVO.getLineId());
        validateRelations(fillReqVO);
        fillReqVO.setItemId(resolveBindingItem(batch, fillReqVO));
        String code = generateBatchCode(line.getCode());
        String code = resolveBatchCode(fillReqVO.getCode(), null);
        // 3. è½åº“业务信息(保留临时批次码用于历史扫码追溯;产线编码/名称冗余回填;勾选绑定托盘时批次直接进入生产中)
        boolean hasBindings = hasBindings(fillReqVO);
        MesProBatchDO updateObj = BeanUtils.toBean(fillReqVO, MesProBatchDO.class)
@@ -187,17 +191,14 @@
        validateRelations(updateReqVO);
        updateReqVO.setItemId(resolveBindingItem(oldBatch, updateReqVO));
        // 3. æœªç”Ÿæˆæ­£å¼æ‰¹æ¬¡å·æˆ–产线变化时重新生成批次号(批次号包含产线编码要素)
        // 3. æ‰¹æ¬¡å·ä»…含年份与流水号,生成后不再随产线变化;未生成正式批次号时补生成
        boolean codeEmpty = StrUtil.isEmpty(oldBatch.getCode());
        boolean lineChanged = !Objects.equals(oldBatch.getLineId(), updateReqVO.getLineId());
        String code = oldBatch.getCode();
        String code = codeEmpty ? resolveBatchCode(updateReqVO.getCode(), oldBatch.getId()) : oldBatch.getCode();
        MesProBatchDO updateObj = BeanUtils.toBean(updateReqVO, MesProBatchDO.class);
        if (codeEmpty || lineChanged) {
            // å˜æ›´äº§çº¿æ—¶ï¼Œç›®æ ‡äº§çº¿å¿…须启用;未变更则仅校验存在(避免停用产线的历史批次无法编辑其他字段)
            MesProLineDO line = (lineChanged || codeEmpty)
                    ? lineService.validateLineExistsAndEnable(updateReqVO.getLineId())
                    : lineService.validateLineExists(updateReqVO.getLineId());
            code = generateBatchCode(line.getCode());
        if (lineChanged || codeEmpty) {
            // å˜æ›´äº§çº¿æ—¶ï¼Œç›®æ ‡äº§çº¿å¿…须启用
            MesProLineDO line = lineService.validateLineExistsAndEnable(updateReqVO.getLineId());
            updateObj.setLineCode(line.getCode()).setLineName(line.getName());
        }
        // 4. æ›´æ–°æ‰¹æ¬¡ï¼ˆå‹¾é€‰ç»‘定托盘时批次直接进入生产中;取消勾选不回退,已绑定托盘不可解绑)
@@ -448,16 +449,50 @@
    }
    /**
     * ç”Ÿæˆæ‰¹æ¬¡å·
     * <p>
     * ç¼–码规则:固定前缀(PC) + æ—¥æœŸ(yyyyMMdd) + è¾“入字符(产线编码) + æµæ°´å·(按日循环)
     * ç¡®å®šæ‰¹æ¬¡å·ï¼šäººå·¥è¾“入优先,未输入则按「当前年份 + 3 ä½æµæ°´ã€è‡ªåŠ¨ç”Ÿæˆ
     *
     * @param lineCode äº§çº¿ç¼–码
     * @return æ‰¹æ¬¡å·
     * @param requestedCode äººå·¥è¾“入的批次号,可为空
     * @param selfId        å½“前批次编号(更新时排除自身,新增传 null)
     * @return æœ€ç»ˆæ‰¹æ¬¡å·
     */
    private String generateBatchCode(String lineCode) {
        String inputChar = StrUtil.emptyToDefault(lineCode, "");
        return autoCodeRecordService.generateAutoCode(MesMdAutoCodeRuleCodeEnum.PRO_BATCH_CODE.getCode(), inputChar);
    private String resolveBatchCode(String requestedCode, Long selfId) {
        if (StrUtil.isBlank(requestedCode)) {
            return generateNextBatchCode();
        }
        String code = StrUtil.trim(requestedCode);
        validateBatchCode(code, selfId);
        return code;
    }
    /**
     * æ ¡éªŒæ‰¹æ¬¡å·æ ¼å¼ï¼ˆ4 ä½å¹´ä»½ + 3 ä½æµæ°´ï¼Œå¦‚ 2026001)及唯一性
     */
    private void validateBatchCode(String code, Long selfId) {
        if (!BATCH_CODE_PATTERN.matcher(code).matches()) {
            throw exception(PRO_BATCH_CODE_INVALID);
        }
        int year = Integer.parseInt(code.substring(0, 4));
        int serial = Integer.parseInt(code.substring(4));
        if (year < 2000 || year > 2100 || serial < 1) {
            throw exception(PRO_BATCH_CODE_INVALID);
        }
        MesProBatchDO existing = batchMapper.selectByCodeIgnoreDeleted(code);
        if (existing != null && !Objects.equals(existing.getId(), selfId)) {
            throw exception(PRO_BATCH_CODE_DUPLICATE, code);
        }
    }
    /**
     * ç”Ÿæˆå½“前年份的下一个批次号
     */
    private String generateNextBatchCode() {
        String year = String.valueOf(LocalDate.now().getYear());
        Integer maxSerial = batchMapper.selectMaxCodeSerialByYear(year);
        int serial = codeRedisDao.allocateBatchNo(year, maxSerial);
        if (serial > BATCH_CODE_MAX_SERIAL) {
            throw exception(PRO_BATCH_CODE_EXHAUSTED);
        }
        return year + String.format("%03d", serial);
    }
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletService.java
@@ -96,6 +96,14 @@
    List<MesProPalletDO> getPalletListByBatchId(Long batchId);
    /**
     * æ‰¹é‡èŽ·å¾—å¤šä¸ªæ‰¹æ¬¡çš„æ‰˜ç›˜ç åˆ—è¡¨
     *
     * @param batchIds æ‰¹æ¬¡ç¼–号列表
     * @return æ‰˜ç›˜ç åˆ—表
     */
    List<MesProPalletDO> getPalletListByBatchIds(Collection<Long> batchIds);
    /**
     * åˆ†é¡µèŽ·å¾—æœªç»‘å®šç”Ÿäº§æ‰¹æ¬¡çš„ä¸´æ—¶æ‰˜ç›˜ï¼ˆä¾›æ‰¹æ¬¡æ–°å¢ž/补录绑定托盘下拉使用)
     *
     * @param reqVO åˆ†é¡µä¸Žå…³é”®å­—条件
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/pallet/MesProPalletServiceImpl.java
@@ -288,6 +288,14 @@
    }
    @Override
    public List<MesProPalletDO> getPalletListByBatchIds(Collection<Long> batchIds) {
        if (CollUtil.isEmpty(batchIds)) {
            return List.of();
        }
        return palletMapper.selectListByBatchIds(batchIds);
    }
    @Override
    public PageResult<MesProPalletDO> getUnboundPalletPage(MesProPalletUnboundPageReqVO reqVO) {
        return palletMapper.selectUnboundPage(reqVO);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/scan/MesProScanEventServiceImpl.java
@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.mes.service.pro.scan;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.pro.scan.vo.MesProScanEventCreateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.scan.vo.MesProScanEventPageReqVO;
@@ -31,9 +33,11 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
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;
@@ -108,6 +112,8 @@
            if (batch == null) return failed(event, "MES_SCAN_BATCH_NOT_EXISTS", "生产批次不存在");
            batchId = batch.getId();
            targetId = pallet.getId();
            event.setPalletId(pallet.getId())
                    .setPalletCode(StrUtil.blankToDefault(pallet.getCode(), pallet.getTempCode()));
            quantity = BigDecimal.valueOf(bagCodeMapper.selectPendingInboundCountByPalletId(pallet.getId()));
        } else if (SCAN_TYPE_BATCH.equals(scanType)) {
            batch = batchMapper.selectByScanCodeForUpdate(scanCode);
@@ -121,6 +127,9 @@
            if (MesProBagCodeStatusEnum.CANCELED.getStatus().equals(bag.getStatus()))
                return failed(event, "MES_SCAN_BAG_CANCELED", "袋码已作废,不可入库");
            event.setBagCodeId(bag.getId());
            if (bag.getPalletId() != null) {
                event.setPalletId(bag.getPalletId()).setPalletCode(bag.getPalletCode());
            }
            bagCodeId = bag.getId();
            // åº“存流水保持原追溯码为规范码,业务码仅用于查找同一袋,避免双码拆成两份库存。
            bagCode = bag.getCode();
@@ -135,7 +144,9 @@
            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);
        // æ‰¹æ¬¡æœªè¡¥å½•正式批次号时回退临时批次码,保证任何扫码类型的事件都带出批次码
        event.setBatchId(batchId).setBatchCode(StrUtil.blankToDefault(batch.getCode(), batch.getTempCode()))
                .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());
@@ -175,6 +186,109 @@
        }
    }
    /**
     * ä¸ºæ‰«ç äº‹ä»¶è¡¥å……托盘码:托盘码事件按扫描内容反查托盘,袋码事件取所属托盘;
     * æ‰¹æ¬¡äº‹ä»¶æ•´æ‰¹å¯èƒ½è·¨å¤šä¸ªæ‰˜ç›˜ï¼Œä¸å½’属单一托盘,保持为空。
     * <p>
     * æ‰˜ç›˜ç ä¸è½åº“,读取时实时反查,保证历史事件也能展示。
     */
    private void fillPalletInfo(List<MesProScanEventDO> events) {
        if (CollUtil.isEmpty(events)) {
            return;
        }
        // 1. æ‰˜ç›˜ç äº‹ä»¶ï¼šæ‰«æå†…容可能是业务码、正式码或临时码,统一反查
        List<String> palletScanCodes = events.stream()
                .filter(event -> SCAN_TYPE_PALLET.equals(event.getScanType()) && StrUtil.isNotBlank(event.getBagCode()))
                .map(MesProScanEventDO::getBagCode).distinct().toList();
        Map<String, MesProPalletDO> palletByScanCode = new HashMap<>();
        if (CollUtil.isNotEmpty(palletScanCodes)) {
            for (MesProPalletDO pallet : palletMapper.selectListByScanCodes(palletScanCodes)) {
                if (StrUtil.isNotBlank(pallet.getBusinessCode())) {
                    palletByScanCode.put(pallet.getBusinessCode(), pallet);
                }
                if (StrUtil.isNotBlank(pallet.getCode())) {
                    palletByScanCode.put(pallet.getCode(), pallet);
                }
                if (StrUtil.isNotBlank(pallet.getTempCode())) {
                    palletByScanCode.put(pallet.getTempCode(), pallet);
                }
            }
        }
        // 2. è¢‹ç äº‹ä»¶ï¼šæŒ‰è¢‹ç ç¼–号或码值反查袋码,再取所属托盘
        List<Long> bagIds = events.stream()
                .filter(event -> SCAN_TYPE_BAG.equals(event.getScanType()))
                .map(MesProScanEventDO::getBagCodeId).filter(Objects::nonNull).distinct().toList();
        List<String> bagScanCodes = events.stream()
                .filter(event -> SCAN_TYPE_BAG.equals(event.getScanType()) && StrUtil.isNotBlank(event.getBagCode()))
                .map(MesProScanEventDO::getBagCode).distinct().toList();
        Map<Long, MesProBagCodeDO> bagById = new HashMap<>();
        Map<String, MesProBagCodeDO> bagByScanCode = new HashMap<>();
        if (CollUtil.isNotEmpty(bagIds)) {
            for (MesProBagCodeDO bag : bagCodeMapper.selectBatchIds(bagIds)) {
                bagById.put(bag.getId(), bag);
            }
        }
        if (CollUtil.isNotEmpty(bagScanCodes)) {
            for (MesProBagCodeDO bag : bagCodeMapper.selectListByScanCodes(bagScanCodes)) {
                bagByScanCode.putIfAbsent(bag.getCode(), bag);
                if (StrUtil.isNotBlank(bag.getBusinessCode())) {
                    bagByScanCode.putIfAbsent(bag.getBusinessCode(), bag);
                }
            }
        }
        // 3. è¢‹ç å†—余托盘码为空时,回查托盘取正式码/临时码
        Set<Long> missingPalletIds = new HashSet<>();
        for (MesProBagCodeDO bag : bagById.values()) {
            if (bag.getPalletId() != null && StrUtil.isBlank(bag.getPalletCode())) {
                missingPalletIds.add(bag.getPalletId());
            }
        }
        for (MesProBagCodeDO bag : bagByScanCode.values()) {
            if (bag.getPalletId() != null && StrUtil.isBlank(bag.getPalletCode())) {
                missingPalletIds.add(bag.getPalletId());
            }
        }
        Map<Long, MesProPalletDO> palletById = new HashMap<>();
        if (CollUtil.isNotEmpty(missingPalletIds)) {
            for (MesProPalletDO pallet : palletMapper.selectBatchIds(missingPalletIds)) {
                palletById.put(pallet.getId(), pallet);
            }
        }
        for (MesProScanEventDO event : events) {
            if (StrUtil.isNotBlank(event.getPalletCode())) {
                continue;
            }
            if (SCAN_TYPE_PALLET.equals(event.getScanType())) {
                MesProPalletDO pallet = palletByScanCode.get(event.getBagCode());
                if (pallet != null) {
                    event.setPalletId(pallet.getId())
                            .setPalletCode(StrUtil.blankToDefault(pallet.getCode(), pallet.getTempCode()));
                }
            } else if (SCAN_TYPE_BAG.equals(event.getScanType())) {
                MesProBagCodeDO bag = event.getBagCodeId() != null
                        ? bagById.get(event.getBagCodeId()) : bagByScanCode.get(event.getBagCode());
                if (bag == null || bag.getPalletId() == null) {
                    continue;
                }
                event.setPalletId(bag.getPalletId());
                if (StrUtil.isNotBlank(bag.getPalletCode())) {
                    event.setPalletCode(bag.getPalletCode());
                } else {
                    MesProPalletDO pallet = palletById.get(bag.getPalletId());
                    if (pallet != null) {
                        event.setPalletCode(StrUtil.blankToDefault(pallet.getCode(), pallet.getTempCode()));
                    }
                }
            }
        }
    }
    private void fillPalletInfo(MesProScanEventDO event) {
        if (event != null) {
            fillPalletInfo(List.of(event));
        }
    }
    private MesProScanEventDO failed(MesProScanEventDO event, String code, String message) {
        event.setStatus(MesProScanEventStatusEnum.FAILED).setErrorCode(code).setErrorMessage(message)
                .setFailedTime(LocalDateTime.now()).setEventTime(LocalDateTime.now());
@@ -182,13 +296,31 @@
        return event;
    }
    @Override public MesProScanEventDO getByEventId(String eventId) { return eventMapper.selectOne(MesProScanEventDO::getEventId, eventId); }
    @Override public PageResult<MesProScanEventDO> getPage(MesProScanEventPageReqVO reqVO) { return eventMapper.selectPage(reqVO); }
    @Override public MesProScanEventDO getLatest(String deviceCode) { return eventMapper.selectLatest(deviceCode); }
    @Override public List<MesProScanEventDO> getByBatchId(Long batchId) { return eventMapper.selectListByBatchId(batchId); }
    @Override public MesProScanEventDO getByEventId(String eventId) {
        MesProScanEventDO event = eventMapper.selectOne(MesProScanEventDO::getEventId, eventId);
        fillPalletInfo(event);
        return event;
    }
    @Override public PageResult<MesProScanEventDO> getPage(MesProScanEventPageReqVO reqVO) {
        PageResult<MesProScanEventDO> page = eventMapper.selectPage(reqVO);
        fillPalletInfo(page.getList());
        return page;
    }
    @Override public MesProScanEventDO getLatest(String deviceCode) {
        MesProScanEventDO event = eventMapper.selectLatest(deviceCode);
        fillPalletInfo(event);
        return event;
    }
    @Override public List<MesProScanEventDO> getByBatchId(Long batchId) {
        List<MesProScanEventDO> events = eventMapper.selectListByBatchId(batchId);
        fillPalletInfo(events);
        return events;
    }
    @Override public Map<String, Object> getDashboard() {
        Map<String, Object> result = new HashMap<>();
        result.put("latest", eventMapper.selectLatest(null));
        MesProScanEventDO latest = eventMapper.selectLatest(null);
        fillPalletInfo(latest);
        result.put("latest", latest);
        result.put("total", eventMapper.selectCount(null));
        result.put("success", eventMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesProScanEventDO>().eq(MesProScanEventDO::getStatus, MesProScanEventStatusEnum.SUCCESS)));
        result.put("failed", eventMapper.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesProScanEventDO>().eq(MesProScanEventDO::getStatus, MesProScanEventStatusEnum.FAILED)));
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedIndicatorRangeService.java
@@ -1,45 +1,121 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedIndicatorRangeSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedIndicatorRangeDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.MesQcSeedIndicatorRangeMapper;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.math.BigDecimal;
import java.util.stream.Collectors;
import java.util.Map;
@Service
public class MesQcSeedIndicatorRangeService {
    private static final Set<String> CODES = Set.of("purity", "cleanliness", "germination_rate", "moisture");
    @Resource private MesQcSeedIndicatorRangeMapper mapper;
    public List<MesQcSeedIndicatorRangeDO> getRanges() {
        return mapper.selectList().stream().filter(item -> CODES.contains(item.getIndicatorCode())).toList();
    @Resource
    private MesQcSeedIndicatorRangeMapper mapper;
    @Resource
    private MesMdmItemService mdmItemService;
    /** é…ç½®é¡µæŸ¥è¯¢ï¼šitemId ä¸ºç©ºè¡¨ç¤ºé€šç”¨é»˜è®¤é¡¹ */
    public List<MesQcSeedIndicatorRangeDO> getRanges(Long itemId, String keyword, Integer status) {
        return mapper.selectListByItemId(itemId, keyword, status);
    }
    /**
     * ç”Ÿæ•ˆæŒ‡æ ‡ï¼šäº§å“ä¸“属配置优先,未配置时回退通用默认项。
     */
    public List<MesQcSeedIndicatorRangeDO> getEffectiveRanges(Long itemId) {
        List<MesQcSeedIndicatorRangeDO> productRanges = itemId == null ? List.of() : mapper.selectEnabledByItemId(itemId);
        return productRanges.isEmpty() ? mapper.selectEnabledDefaults() : productRanges;
    }
    public Map<String, MesQcSeedIndicatorRangeDO> getEffectiveMap(Long itemId) {
        Map<String, MesQcSeedIndicatorRangeDO> result = new LinkedHashMap<>();
        for (MesQcSeedIndicatorRangeDO range : getEffectiveRanges(itemId)) {
            result.put(range.getIndicatorCode(), range);
        }
        return result;
    }
    @Transactional(rollbackFor = Exception.class)
    public void saveRanges(MesQcSeedIndicatorRangeSaveReqVO request) {
        List<MesQcSeedIndicatorRangeSaveReqVO.Item> items = request.getRanges();
        if (items == null || items.size() != CODES.size() || items.stream().anyMatch(item -> item == null || item.getIndicatorCode() == null
                || item.getMinValue() == null || item.getMaxValue() == null)
                || !items.stream().map(MesQcSeedIndicatorRangeSaveReqVO.Item::getIndicatorCode)
                .collect(Collectors.toSet()).equals(CODES))
            throw new IllegalArgumentException("必须完整配置纯度、净度、发芽率、水分四项指标");
        for (MesQcSeedIndicatorRangeSaveReqVO.Item item : items) {
            if (item.getMinValue().compareTo(BigDecimal.ZERO) < 0 || item.getMaxValue().compareTo(new BigDecimal("100")) > 0
                    || item.getMinValue().compareTo(item.getMaxValue()) > 0)
                throw new IllegalArgumentException("指标合格范围必须处于 0–100 ä¸”下限不大于上限:" + item.getIndicatorCode());
            MesQcSeedIndicatorRangeDO row = new MesQcSeedIndicatorRangeDO();
            row.setIndicatorCode(item.getIndicatorCode());
            row.setMinValue(item.getMinValue());
            row.setMaxValue(item.getMaxValue());
            if (mapper.selectById(item.getIndicatorCode()) == null) mapper.insert(row);
            else mapper.updateById(row);
        }
    public Long create(MesQcSeedIndicatorRangeSaveReqVO reqVO) {
        validateProduct(reqVO.getItemId());
        validateRange(reqVO);
        if (mapper.selectByItemIdAndCode(reqVO.getItemId(), reqVO.getIndicatorCode()) != null)
            throw new IllegalArgumentException("该产品已存在相同编码的质检项:" + reqVO.getIndicatorCode());
        MesQcSeedIndicatorRangeDO row = buildRow(reqVO);
        mapper.insert(row);
        return row.getId();
    }
    @Transactional(rollbackFor = Exception.class)
    public void update(Long id, MesQcSeedIndicatorRangeSaveReqVO reqVO) {
        MesQcSeedIndicatorRangeDO existing = mapper.selectById(id);
        if (existing == null) throw new IllegalArgumentException("质检项不存在或已删除");
        validateProduct(reqVO.getItemId());
        validateRange(reqVO);
        MesQcSeedIndicatorRangeDO sameCode = mapper.selectByItemIdAndCode(reqVO.getItemId(), reqVO.getIndicatorCode());
        if (sameCode != null && !sameCode.getId().equals(id))
            throw new IllegalArgumentException("该产品已存在相同编码的质检项:" + reqVO.getIndicatorCode());
        MesQcSeedIndicatorRangeDO update = buildRow(reqVO);
        update.setId(id);
        mapper.updateById(update);
    }
    @Transactional(rollbackFor = Exception.class)
    public void delete(Long id) {
        MesQcSeedIndicatorRangeDO existing = mapper.selectById(id);
        if (existing == null) throw new IllegalArgumentException("质检项不存在或已删除");
        mapper.deleteById(id);
    }
    /** å°†é€šç”¨é»˜è®¤é¡¹å¤åˆ¶åˆ°æŒ‡å®šäº§å“ï¼Œå·²å­˜åœ¨çš„编码跳过 */
    @Transactional(rollbackFor = Exception.class)
    public int copyDefaults(Long itemId) {
        validateProduct(itemId);
        int copied = 0;
        for (MesQcSeedIndicatorRangeDO source : mapper.selectEnabledDefaults()) {
            if (mapper.selectByItemIdAndCode(itemId, source.getIndicatorCode()) != null) continue;
            MesQcSeedIndicatorRangeDO row = new MesQcSeedIndicatorRangeDO();
            row.setItemId(itemId);
            row.setIndicatorCode(source.getIndicatorCode());
            row.setIndicatorName(source.getIndicatorName());
            row.setUnit(source.getUnit());
            row.setMinValue(source.getMinValue());
            row.setMaxValue(source.getMaxValue());
            row.setSort(source.getSort());
            row.setStatus(0);
            mapper.insert(row);
            copied++;
        }
        return copied;
    }
    private void validateProduct(Long itemId) {
        if (itemId == null) throw new IllegalArgumentException("请先选择产品");
        if (mdmItemService.getItem(itemId) == null) throw new IllegalArgumentException("产品不存在,请重新选择");
    }
    private void validateRange(MesQcSeedIndicatorRangeSaveReqVO reqVO) {
        if (reqVO.getMinValue().compareTo(reqVO.getMaxValue()) > 0)
            throw new IllegalArgumentException("合格下限不能大于上限:" + reqVO.getIndicatorName());
    }
    private MesQcSeedIndicatorRangeDO buildRow(MesQcSeedIndicatorRangeSaveReqVO reqVO) {
        MesQcSeedIndicatorRangeDO row = new MesQcSeedIndicatorRangeDO();
        row.setItemId(reqVO.getItemId());
        row.setIndicatorCode(reqVO.getIndicatorCode().trim());
        row.setIndicatorName(reqVO.getIndicatorName().trim());
        row.setUnit(StrUtil.blankToDefault(reqVO.getUnit(), "%"));
        row.setMinValue(reqVO.getMinValue());
        row.setMaxValue(reqVO.getMaxValue());
        row.setSort(reqVO.getSort() == null ? 0 : reqVO.getSort());
        row.setStatus(reqVO.getStatus() == null ? 0 : reqVO.getStatus());
        return row;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityService.java
@@ -14,19 +14,46 @@
    java.util.Map<String, Object> resolveScan(String code);
    java.util.Map<String, Object> resolveScan(String code, String scanType);
    java.util.Map<String, Object> resolveScan(String code, String scanType, Integer qcLine);
    java.util.Map<String, Object> submitGroup(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO reqVO);
    void retest(Long id, cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityRetestReqVO reqVO);
    void retestBatch(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRetestReqVO reqVO);
    java.util.Map<String, Object> resolveRetestScope(String code, String scanType);
    java.util.Map<String, Object> resolveRetestScope(String code, String scanType, Integer qcLine);
    java.util.Map<String, Object> retestScope(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityScopeRetestReqVO reqVO);
    void processApprove(Long id, boolean approved, String opinion);
    void companyApprove(Long id, boolean approved, String opinion);
    void processApproveBatch(List<Long> ids, boolean approved, String opinion);
    void companyApproveBatch(List<Long> ids, boolean approved, String opinion);
    /** è§£æžç»é”€å•†é€€è´§å•的待质检信息 */
    java.util.Map<String, Object> resolveReturnQuality(Long returnSalesId);
    /** æäº¤ç»é”€å•†é€€è´§è´¨æ£€ */
    Long submitReturnQuality(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedReturnQualitySubmitReqVO reqVO);
    /** ç»é”€å•†é€€è´§è´¨æ£€é”€å”®éƒ¨é¢†å¯¼å®¡æ‰¹ */
    void returnApprove(Long id, boolean approved, String opinion);
    /** ç®¡ç†å‘˜åœ¨æµç¨‹å®šä¹‰å‘布后,对尚未生成 BPM å®žä¾‹çš„记录重试发起审批。 */
    void retryBpmProcess(Long id);
    /** BPM å›žè°ƒï¼šæœ¬çº¿é¢†å¯¼å®¡æ‰¹å®Œæˆ */
    void handleLineProcessResult(String processInstanceId, Integer qcLine, String batchCode, boolean approved, String reason);
    /** BPM å›žè°ƒï¼šå…¬å¸è´¨æ£€å®¡æ‰¹å®Œæˆï¼ˆbusinessKey ä¸ºç”Ÿäº§æ‰¹æ¬¡å·ï¼ŒåŽ†å²æ•°æ®å¯èƒ½ä¸ºè¢‹ç ï¼‰ */
    void handleCompanyProcessResult(String processInstanceId, String businessKey, boolean approved, String reason);
    /** BPM å›žè°ƒï¼šç»é”€å•†é€€è´§é”€å”®éƒ¨å®¡æ‰¹å®Œæˆ */
    void handleReturnProcessResult(String processInstanceId, Long returnSalesId, boolean approved, String reason);
    PageResult<MesQcSeedQualityDO> getQualityPage(MesQcSeedQualityPageReqVO reqVO);
    /**
     * æŒ‰ç”Ÿäº§æ‰¹æ¬¡åˆ†ç»„分页查询质检记录汇总,未绑定批次的记录归入空批次组。
     *
     * @param reqVO æŸ¥è¯¢æ¡ä»¶ï¼ˆä¸Žæ˜Žç»†åˆ†é¡µä¸€è‡´ï¼‰
     * @return æ‰¹æ¬¡åˆ†ç»„分页结果
     */
    PageResult<cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRespVO>
            getQualityBatchPage(MesQcSeedQualityPageReqVO reqVO);
    /**
     * æ ¹æ®æ‰¹æ¬¡å·æŸ¥è¯¢è´¨æ£€è®°å½•(按检验时间倒序,取最新),用于公开溯源。
     * ç§å­è´¨æ£€è¡¨æœªåˆå§‹åŒ–时返回空列表(容错,不影响追溯)。
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java
@@ -5,12 +5,21 @@
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRespVO;
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.controller.admin.qc.seedquality.vo.MesQcSeedReturnQualitySubmitReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
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.qc.seedquality.*;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
@@ -31,6 +40,8 @@
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.qc.approver.MesQcApproverService;
import cn.iocoder.yudao.module.mes.service.wm.materialstockbag.MesWmMaterialStockBagService;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesLineService;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
@@ -41,10 +52,15 @@
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@@ -55,11 +71,23 @@
@Service @Validated @Slf4j
public class MesQcSeedQualityServiceImpl implements MesQcSeedQualityService {
    private static final Set<String> REQUIRED = Set.of("purity", "cleanliness", "germination_rate", "moisture");
    private static final int MAX_BATCH_SIZE = 500, WRITE_BATCH_SIZE = 100;
    private static final int WAIT_RETEST = 10, WAIT_PROCESS = 20, WAIT_INITIAL_FAIL_REVIEW = 25, WAIT_COMPANY = 30,
            RELEASED = 40, WAIT_INITIAL_RECHECK = 50, WAIT_COMPANY_FAIL = 60, SCRAPPED = 70;
    /** è´¨æ£€çº¿ï¼š1 åŠ å·¥éƒ¨ï¼›2 é”€å”®éƒ¨ */
    private static final int QC_LINE_PROCESS = 1, QC_LINE_SALES = 2;
    /** ä¸šåŠ¡ç±»åž‹ï¼š1 ç”Ÿäº§å…¥åº“质检;2 ç»é”€å•†é€€è´§è´¨æ£€ */
    private static final int BIZ_TYPE_PRODUCTION = 1, BIZ_TYPE_RETURN = 2;
    /** æœ¬çº¿é¢†å¯¼å®¡æ‰¹æµç¨‹å®šä¹‰æ ‡è¯† */
    private static final String LINE_PROCESS_KEY = "mes_seed_quality_line_approve";
    /** å…¬å¸è´¨æ£€å®¡æ‰¹æµç¨‹å®šä¹‰æ ‡è¯† */
    private static final String COMPANY_PROCESS_KEY = "mes_seed_quality_company_approve";
    /** ç»é”€å•†é€€è´§è´¨æ£€å®¡æ‰¹æµç¨‹å®šä¹‰æ ‡è¯† */
    private static final String RETURN_PROCESS_KEY = "mes_seed_return_approve";
    @Resource private MesQcSeedQualityMapper qualityMapper;
    @Resource private BpmProcessInstanceApi processInstanceApi;
    @Resource private MesWmReturnSalesService returnSalesService;
    @Resource private MesWmReturnSalesLineService returnSalesLineService;
    @Resource private MesQcApproverService approverService;
    @Resource private MesQcSeedQualityActionMapper actionMapper;
    @Resource private MesQcSeedQualityIndicatorMapper indicatorMapper;
@@ -75,14 +103,93 @@
    @Resource private MesWmWarehouseService warehouseService;
    @Resource private MesWmWarehouseLocationService warehouseLocationService;
    @Resource private MesWmWarehouseAreaService warehouseAreaService;
    @Resource private MesMdmItemService mdmItemService;
    @Resource private MesQcSeedIndicatorRangeService rangeService;
    @Override
    public PageResult<MesQcSeedQualityDO> getQualityPage(MesQcSeedQualityPageReqVO reqVO) {
        resolveBagScanCode(reqVO);
        return qualityMapper.selectPage(reqVO);
    }
    @Override
    public PageResult<MesQcSeedQualityBatchRespVO> getQualityBatchPage(MesQcSeedQualityPageReqVO reqVO) {
        resolveBagScanCode(reqVO);
        int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo();
        int pageSize = reqVO.getPageSize() == null || reqVO.getPageSize() < 1 ? 10 : Math.min(reqVO.getPageSize(), 100);
        long total = qualityMapper.countBatchGroups(reqVO);
        if (total == 0) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        List<MesQcSeedQualityBatchRespVO> list = qualityMapper.selectBatchGroups(reqVO, (long) (pageNo - 1) * pageSize, pageSize)
                .stream().map(this::toBatchRespVO).collect(Collectors.toList());
        // å›žå¡«æ‰¹æ¬¡ä¸Žå“ç§ä¿¡æ¯
        Set<Long> batchIds = list.stream().map(MesQcSeedQualityBatchRespVO::getBatchId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, MesProBatchDO> batchMap = batchIds.isEmpty() ? Collections.emptyMap()
                : batchMapper.selectBatchIds(batchIds).stream()
                .collect(Collectors.toMap(MesProBatchDO::getId, Function.identity(), (first, ignored) -> first));
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(list.stream()
                .map(MesQcSeedQualityBatchRespVO::getItemId).filter(Objects::nonNull).collect(Collectors.toSet()));
        for (MesQcSeedQualityBatchRespVO vo : list) {
            vo.setBatchLabel(StrUtil.blankToDefault(vo.getBatchCode(), "未绑定批次"));
            MesProBatchDO batch = batchMap.get(vo.getBatchId());
            if (batch != null) {
                vo.setLineName(batch.getLineName());
                vo.setProduceDate(batch.getProduceDate());
            }
            MesMdmItemDO item = itemMap.get(vo.getItemId());
            if (item != null) {
                vo.setItemCode(item.getCode());
                vo.setItemName(item.getName());
            }
        }
        return new PageResult<>(list, total);
    }
    private void resolveBagScanCode(MesQcSeedQualityPageReqVO reqVO) {
        if (StrUtil.isNotBlank(reqVO.getCode())) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(reqVO.getCode());
            if (bag != null) reqVO.setCode(bag.getCode());
            if (bag != null) {
                reqVO.setCode(bag.getCode());
            }
        }
        return qualityMapper.selectPage(reqVO);
    }
    private MesQcSeedQualityBatchRespVO toBatchRespVO(Map<String, Object> row) {
        MesQcSeedQualityBatchRespVO vo = new MesQcSeedQualityBatchRespVO();
        vo.setBatchCode((String) row.get("batchCode"));
        vo.setBatchId(toLong(row.get("batchId")));
        vo.setItemId(toLong(row.get("itemId")));
        vo.setTotalCount(toInt(row.get("totalCount")));
        vo.setPassCount(toInt(row.get("passCount")));
        vo.setFailCount(toInt(row.get("failCount")));
        vo.setProcessingCount(toInt(row.get("processingCount")));
        vo.setPendingRetestCount(toInt(row.get("pendingRetestCount")));
        vo.setPendingProcessReviewCount(toInt(row.get("pendingProcessReviewCount")));
        vo.setPendingCompanyReviewCount(toInt(row.get("pendingCompanyReviewCount")));
        vo.setReleasedCount(toInt(row.get("releasedCount")));
        vo.setScrappedCount(toInt(row.get("scrappedCount")));
        vo.setLastInspectedAt(toLocalDateTime(row.get("lastInspectedAt")));
        return vo;
    }
    private static Long toLong(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
    private static Integer toInt(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
    }
    private static LocalDateTime toLocalDateTime(Object value) {
        if (value instanceof LocalDateTime dateTime) {
            return dateTime;
        }
        if (value instanceof Timestamp timestamp) {
            return timestamp.toLocalDateTime();
        }
        return null;
    }
    @Override
@@ -95,9 +202,28 @@
        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());
        Long itemId = bag.getItemId();
        MesMdmItemDO item = itemId == null ? null : mdmItemService.getItem(itemId);
        result.put("itemId", itemId);
        result.put("itemCode", item == null ? null : item.getCode());
        result.put("itemName", item == null ? null : item.getName());
        result.put("indicators", toRangePayload(itemId));
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByCode(bag.getCode());
        MesQcSeedQualityDO quality = qualities.stream()
                .filter(q -> !isReturnQuality(q)).findFirst().orElse(null);
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        result.put("qualityIds", qualities.stream().map(MesQcSeedQualityDO::getId).toList());
        result.put("qualityLines", qualities.stream()
                .filter(q -> !isReturnQuality(q))
                .map(q -> {
                    Map<String, Object> line = new HashMap<>();
                    line.put("id", q.getId());
                    line.put("qcLine", q.getQcLine());
                    line.put("workflowStatus", q.getWorkflowStatus());
                    return line;
                })
                .toList());
        return result;
    }
@@ -127,9 +253,17 @@
    @Override
    public Map<String, Object> resolveScan(String code, String scanType) {
        return resolveScan(code, scanType, QC_LINE_PROCESS);
    }
    @Override
    public Map<String, Object> resolveScan(String code, String scanType, Integer qcLine) {
        requireQcLine(qcLine);
        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()
                .filter(q -> !isReturnQuality(q))
                .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());
@@ -138,9 +272,19 @@
        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());
        Long itemId = resolveItemId(scope);
        MesMdmItemDO item = itemId == null ? null : mdmItemService.getItem(itemId);
        result.put("itemId", itemId);
        result.put("itemCode", item == null ? null : item.getCode());
        result.put("itemName", item == null ? null : item.getName());
        result.put("indicators", toRangePayload(itemId));
        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());
            List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByCode(scope.code());
            MesQcSeedQualityDO quality = qualities.stream()
                    .filter(q -> !isReturnQuality(q)).findFirst().orElse(null);
            result.put("qualityId", quality == null ? null : quality.getId());
            result.put("result", quality == null ? null : quality.getResult());
            result.put("qualityIds", qualities.stream().map(MesQcSeedQualityDO::getId).toList());
        }
        return result;
    }
@@ -152,7 +296,9 @@
        if (scope.canceled()) throw invalidParamException("质检对象已作废");
        List<MesProBagCodeDO> effective = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).toList();
        if (effective.isEmpty()) throw new IllegalArgumentException("没有有效袋码");
        // ä¸€æ¬¡æäº¤åŒæ—¶ç”Ÿæˆä¸¤æ¡è´¨æ£€çº¿è®°å½•,任一已存在即视为该袋已质检。
        Set<String> inspected = qualityMapper.selectListByCodes(effective.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .filter(q -> !isReturnQuality(q))
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        List<MesProBagCodeDO> pendingPreview = effective.stream().filter(b -> b.getInboundEventId() != null && !inspected.contains(b.getCode()))
                .sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
@@ -168,7 +314,8 @@
        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.containsAll(confirmed))
            throw new IllegalArgumentException("袋码归属已变化,请重新扫描核对");
        if (!qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).isEmpty())
        if (qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .anyMatch(q -> !isReturnQuality(q)))
            throw new IllegalArgumentException("待质检清单已变化或已全部质检,请重新扫描,禁止重复处理");
        List<MesProBagCodeDO> pending = locked.stream().sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
        submitInitialBatch(pending, req);
@@ -177,13 +324,14 @@
    private void submitInitialBatch(List<MesProBagCodeDO> bags,
                                    cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO req) {
        boolean passed = validateIndicators(req.getIndicators());
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        Long batchId = bags.get(0).getBatchId();
        if (batchId == null || bags.stream().anyMatch(bag -> !Objects.equals(batchId, bag.getBatchId())))
            throw new IllegalArgumentException("批量初检只允许同一生产批次的袋码");
        MesProBatchDO batch = proBatchService.getBatch(batchId);
        if (batch == null || batch.getItemId() == null) throw new IllegalArgumentException("未根据袋码解析到物料信息");
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(batch.getItemId());
        boolean passed = validateIndicators(req.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(req.getReason())) 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)
@@ -193,37 +341,48 @@
        if (qualifiedWarehouse == null) throw exception(WM_WAREHOUSE_NOT_EXISTS);
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType()))
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        // æå‰æ ¡éªŒåˆæ ¼åº“已配置库区/库位,避免复检审核放行时才失败
        resolveQualifiedLocation(qualifiedWarehouse);
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        int status = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        List<MesQcSeedQualityDO> qualities = bags.stream().map(bag -> MesQcSeedQualityDO.builder()
                .code(bag.getCode()).itemId(batch.getItemId()).batchId(batch.getId()).batchCode(batch.getCode())
                .result(result).reason(passed ? "" : req.getReason().trim()).workflowStatus(status)
                .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId).inspectedAt(now).build()).toList();
        int status = passed ? WAIT_PROCESS : WAIT_RETEST;
        // ä¸€æ¬¡æäº¤åŒæ—¶ç”ŸæˆåŠ å·¥éƒ¨ä¸Žé”€å”®éƒ¨ä¸¤æ¡è®°å½•ï¼ŒæŒ‡æ ‡ä¸Žç»“è®ºå®Œå…¨ä¸€è‡´ã€‚
        List<MesQcSeedQualityDO> qualities = new ArrayList<>(bags.size() * 2);
        for (MesProBagCodeDO bag : bags) {
            for (int qcLine : new int[]{QC_LINE_PROCESS, QC_LINE_SALES}) {
                qualities.add(MesQcSeedQualityDO.builder()
                        .code(bag.getCode()).qcLine(qcLine).bizType(BIZ_TYPE_PRODUCTION)
                        .itemId(batch.getItemId()).batchId(batch.getId()).batchCode(batch.getCode())
                        .result(result).reason(passed ? "" : req.getReason().trim()).workflowStatus(status)
                        .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId).inspectedAt(now).build());
            }
        }
        qualityMapper.insertBatch(qualities, WRITE_BATCH_SIZE);
        if (qualities.stream().anyMatch(quality -> quality.getId() == null))
            throw new IllegalStateException("批量初检记录编号生成失败");
        List<MesQcSeedQualityIndicatorDO> indicators = new ArrayList<>(qualities.size() * REQUIRED.size());
        List<MesQcSeedQualityIndicatorDO> indicators = new ArrayList<>(qualities.size() * expected.size());
        List<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        for (MesQcSeedQualityDO quality : qualities) {
            for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
                IndicatorMeta meta = expected.get(item.getCode());
                indicators.add(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                        .indicatorName(meta.name()).unit(meta.unit())
                        .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
            }
            actions.add(newAction(quality.getId(), "INITIAL", result, req.getReason(), req.getIndicators(), operatorId, now));
        }
        indicatorMapper.insertBatch(indicators, WRITE_BATCH_SIZE * REQUIRED.size());
        indicatorMapper.insertBatch(indicators, WRITE_BATCH_SIZE * expected.size());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
        String batchCode = batch.getCode();
        if (passed) {
            afterCommit(() -> startLineProcess(QC_LINE_PROCESS, batchCode, operatorId));
            afterCommit(() -> startLineProcess(QC_LINE_SALES, batchCode, operatorId));
        }
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Long submit(MesQcSeedQualitySubmitReqVO reqVO) {
        if (reqVO.getIndicators().size() != 4) throw new IllegalArgumentException("必须提交四项指标");
        // ç§å­è´¨æ£€å¿…须以袋码(单袋)提交,物料信息由袋码自动解析,前端不再单独选择物料
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(reqVO.getCode());
        if (bag == null) {
@@ -232,7 +391,9 @@
        if (Integer.valueOf(30).equals(bag.getStatus()) || bag.getInboundEventId() == null)
            throw new IllegalArgumentException("袋码已作废或尚未入库,无法质检");
        // åŒç é”å®šåŒä¸€è¢‹ï¼Œå¹¶ä»¥è¿½æº¯ç è®°å½•结果,防止换码或并发重复扣减库存。
        if (qualityMapper.selectByCodeForUpdate(bag.getCode()) != null) throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        if (qualityMapper.selectListByCodeForUpdate(bag.getCode()).stream()
                .anyMatch(q -> !isReturnQuality(q)))
            throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        MesProBatchDO batch = bag.getBatchId() == null ? null : proBatchService.getBatch(bag.getBatchId());
        if (batch == null || batch.getItemId() == null) {
            throw new IllegalArgumentException("未根据袋码解析到物料信息,请确认扫码内容或批次号正确");
@@ -254,35 +415,39 @@
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType())) {
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        }
        // æå‰æ ¡éªŒåˆæ ¼åº“已配置库区/库位,避免复检审核放行时才失败
        resolveQualifiedLocation(qualifiedWarehouse);
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(bag.getCode()).itemId(batch.getItemId())
                .batchCode(batch.getCode()).batchId(batch.getId()).reason(reqVO.getReason()).result(0)
                .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(getLoginUserId())
                .inspectedAt(LocalDateTime.now()).build();
        qualityMapper.insert(quality);
        for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
            if (item == null || item.getCode() == null || item.getValue() == null || item.getMinValue() == null || item.getMaxValue() == null)
                throw new IllegalArgumentException("四项指标的实测值和本次合格范围必须完整");
            if (!REQUIRED.contains(item.getCode()) || !codes.add(item.getCode())) throw new IllegalArgumentException("指标必须为纯度、净度、发芽率、水分且不可重复");
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("指标下限不能大于上限");
            if (item.getValue().compareTo(BigDecimal.ZERO) < 0 || item.getValue().compareTo(new BigDecimal("100")) > 0
                    || item.getMinValue().compareTo(BigDecimal.ZERO) < 0 || item.getMaxValue().compareTo(new BigDecimal("100")) > 0)
                throw new IllegalArgumentException("百分比指标必须处于 0–100");
            if (item.getValue().compareTo(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
            indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
        if (!codes.equals(REQUIRED)) throw new IllegalArgumentException("四项指标不完整");
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(batch.getItemId());
        boolean passed = validateIndicators(reqVO.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        quality.setResult(passed ? 1 : 2);
        quality.setWorkflowStatus(passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW);
        qualityMapper.updateById(quality);
        recordAction(quality.getId(), "INITIAL", quality.getResult(), reqVO.getReason(), reqVO.getIndicators());
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        int status = passed ? WAIT_PROCESS : WAIT_RETEST;
        // ä¸€æ¬¡æäº¤åŒæ—¶ç”ŸæˆåŠ å·¥éƒ¨ä¸Žé”€å”®éƒ¨ä¸¤æ¡è®°å½•ï¼ŒæŒ‡æ ‡ä¸Žç»“è®ºå®Œå…¨ä¸€è‡´ã€‚
        Long firstQualityId = null;
        for (int qcLine : new int[]{QC_LINE_PROCESS, QC_LINE_SALES}) {
            MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(bag.getCode())
                    .qcLine(qcLine).bizType(BIZ_TYPE_PRODUCTION)
                    .itemId(batch.getItemId()).batchCode(batch.getCode()).batchId(batch.getId())
                    .reason(passed ? "" : reqVO.getReason().trim()).result(result).workflowStatus(status)
                    .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId)
                    .inspectedAt(now).build();
            qualityMapper.insert(quality);
            for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
                IndicatorMeta meta = expected.get(item.getCode());
                indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                        .indicatorName(meta.name()).unit(meta.unit())
                        .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
            }
            recordAction(quality.getId(), "INITIAL", result, reqVO.getReason(), reqVO.getIndicators());
            if (firstQualityId == null) firstQualityId = quality.getId();
        }
        // åˆæ£€ä»…记录结论;袋仍留在暂存库,待初检审核、复检及最终审核后再放行或报废。
        return quality.getId();
        String batchCode = batch.getCode();
        if (passed) {
            afterCommit(() -> startLineProcess(QC_LINE_PROCESS, batchCode, operatorId));
            afterCommit(() -> startLineProcess(QC_LINE_SALES, batchCode, operatorId));
        }
        return firstQualityId;
    }
    private void finalizeStock(MesQcSeedQualityDO quality, boolean passed) {
@@ -370,25 +535,47 @@
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能提交复检,请刷新后重试");
        boolean returnQuality = Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
        if (returnQuality && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
            throw new IllegalArgumentException("经销商退货质检当前状态不能复检,请刷新后重试");
        boolean initialRecheck = Objects.equals(quality.getWorkflowStatus(), WAIT_INITIAL_RECHECK);
        if (!Objects.equals(quality.getCode(), req.getCode())) throw new IllegalArgumentException("检验袋码与原质检记录不一致");
        boolean passed = validateIndicators(req.getIndicators());
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("检验不合格必须填写原因");
        recordAction(id, initialRecheck ? "INITIAL_RECHECK" : "RETEST", passed ? 1 : 2, req.getReason(), req.getIndicators());
        MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(id).result(passed ? 1 : 2)
                .reason(passed ? "" : req.getReason().trim())
                .inspectorId(getLoginUserId()).inspectedAt(LocalDateTime.now())
                .workflowStatus(initialRecheck ? (passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW)
                        : (passed ? WAIT_COMPANY : WAIT_COMPANY_FAIL)).build();
        if (!initialRecheck) {
            update.setRetestResult(passed ? 1 : 2);
            update.setRetestedAt(LocalDateTime.now());
        }
        qualityMapper.updateById(update);
        // é¡µé¢ã€å…¬å¼€è¿½æº¯è¯»å–主指标表;同步为最新一次测值,历次测值在操作留痕表保留。
        // å¤æ£€æ²¿ç”¨åŽŸè®°å½•çš„æŒ‡æ ‡é›†åˆï¼Œé¿å…é…ç½®å˜æ›´å¯¼è‡´åŽ†å²è®°å½•æ— æ³•å¤æ£€
        Map<String, MesQcSeedQualityIndicatorDO> existing = new HashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicatorMapper.selectListByQualityId(id))
            existing.put(indicator.getIndicatorCode(), indicator);
        boolean passed = validateIndicators(req.getIndicators(), toMetaMap(existing.values(), quality.getItemId()));
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("检验不合格必须填写原因");
        recordAction(id, initialRecheck ? "INITIAL_RECHECK" : "RETEST", passed ? 1 : 2, req.getReason(), req.getIndicators());
        Long operatorId = getLoginUserId();
        LocalDateTime now = LocalDateTime.now();
        int nextStatus;
        if (returnQuality) {
            // å¤æ£€ç»“论统一交由销售部领导审批,不合格时审批后不予退货。
            nextStatus = WAIT_PROCESS;
        } else if (initialRecheck) {
            nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        } else {
            nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        }
        MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(id).result(passed ? 1 : 2)
                .reason(passed ? "" : req.getReason().trim())
                .inspectorId(operatorId).inspectedAt(now)
                .workflowStatus(nextStatus).build();
        if (!initialRecheck) {
            update.setRetestResult(passed ? 1 : 2);
            update.setRetestedAt(now);
        }
        qualityMapper.updateById(update);
        if (returnQuality) {
            afterCommit(() -> startReturnProcess(quality, operatorId));
        } else if (initialRecheck) {
            afterCommit(() -> startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId));
        } else if (!initialRecheck) {
            // åˆæ£€ä¸åˆæ ¼çš„复检完成后,先交本线部门审批。
            afterCommit(() -> startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId));
        }
        // é¡µé¢ã€å…¬å¼€è¿½æº¯è¯»å–主指标表;同步为最新一次测值,历次测值在操作留痕表保留。
        for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
            MesQcSeedQualityIndicatorDO indicator = existing.get(item.getCode());
            if (indicator == null) throw new IllegalArgumentException("质检指标记录缺失");
@@ -404,6 +591,7 @@
        validateLockedIds(ids, qualities);
        String batchCode = null;
        Integer workflowStatus = null;
        Integer batchQcLine = null;
        boolean first = true;
        for (MesQcSeedQualityDO quality : qualities) {
            if (!Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
@@ -413,9 +601,12 @@
            if (first) {
                batchCode = quality.getBatchCode();
                workflowStatus = quality.getWorkflowStatus();
                batchQcLine = quality.getQcLine();
                first = false;
            } else if (!Objects.equals(batchCode, quality.getBatchCode()) || !Objects.equals(workflowStatus, quality.getWorkflowStatus())) {
                throw new IllegalArgumentException("批量质检只允许同生产批次、同流程状态的记录");
            } else if (!Objects.equals(batchCode, quality.getBatchCode())
                    || !Objects.equals(workflowStatus, quality.getWorkflowStatus())
                    || !Objects.equals(batchQcLine, quality.getQcLine())) {
                throw new IllegalArgumentException("批量质检只允许同生产批次、同质检线和同流程状态的记录");
            }
        }
        applyRetestBatch(qualities, req.getIndicators(), req.getReason());
@@ -428,12 +619,19 @@
    @Override
    public Map<String, Object> resolveRetestScope(String code, String scanType) {
        return resolveRetestScope(code, scanType, QC_LINE_PROCESS);
    }
    @Override
    public Map<String, Object> resolveRetestScope(String code, String scanType, Integer qcLine) {
        requireQcLine(qcLine);
        checkRetestScanType(scanType);
        Scope scope = scope(code, scanType, false);
        if (scope.canceled()) throw invalidParamException("质检对象已作废");
        List<String> codes = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus()) && b.getInboundEventId() != null)
                .map(MesProBagCodeDO::getCode).toList();
        List<MesQcSeedQualityDO> qualities = codes.isEmpty() ? List.of() : qualityMapper.selectListByCodes(codes);
        List<MesQcSeedQualityDO> qualities = codes.isEmpty() ? List.of() : qualityMapper.selectListByCodes(codes).stream()
                .filter(q -> Objects.equals(q.getQcLine(), qcLine) && !isReturnQuality(q)).toList();
        List<Long> retestIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_RETEST))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> initialRecheckIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_INITIAL_RECHECK))
@@ -454,11 +652,19 @@
        result.put("pendingCompanyIds", pendingCompanyIds); result.put("pendingCompanyFailIds", pendingCompanyFailIds);
        result.put("pendingProcessCount", pendingProcessIds.size() + pendingInitialFailIds.size());
        result.put("pendingCompanyCount", pendingCompanyIds.size() + pendingCompanyFailIds.size());
        Long itemId = qualities.stream().map(MesQcSeedQualityDO::getItemId).filter(Objects::nonNull).findFirst().orElse(null);
        result.put("itemId", itemId);
        MesQcSeedQualityDO actionable = qualities.stream()
                .filter(q -> Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(q.getWorkflowStatus()))
                .findFirst().orElse(null);
        result.put("indicators", actionable == null ? List.of()
                : toIndicatorPayload(indicatorMapper.selectListByQualityId(actionable.getId()), itemId));
        return result;
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> retestScope(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityScopeRetestReqVO req) {
        requireQcLine(req.getQcLine());
        checkRetestScanType(req.getScanType());
        if (!Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(req.getWorkflowStatus()))
            throw new IllegalArgumentException("批量质检状态无效");
@@ -470,7 +676,8 @@
        if (effective.isEmpty()) throw new IllegalArgumentException("当前范围没有已入库的有效袋码");
        Set<String> effectiveCodes = effective.stream().map(MesProBagCodeDO::getCode).collect(java.util.stream.Collectors.toSet());
        List<MesQcSeedQualityDO> pendingPreview = qualityMapper.selectListByCodes(effectiveCodes).stream()
                .filter(q -> Objects.equals(q.getWorkflowStatus(), req.getWorkflowStatus()))
                .filter(q -> Objects.equals(q.getQcLine(), req.getQcLine())
                        && !isReturnQuality(q) && Objects.equals(q.getWorkflowStatus(), req.getWorkflowStatus()))
                .sorted(Comparator.comparing(MesQcSeedQualityDO::getId)).toList();
        List<Long> expectedChunk = pendingPreview.stream().limit(MAX_BATCH_SIZE).map(MesQcSeedQualityDO::getId).toList();
        if (expectedChunk.isEmpty() || !confirmed.equals(expectedChunk))
@@ -478,6 +685,7 @@
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, pending);
        if (pending.stream().anyMatch(quality -> !Objects.equals(quality.getWorkflowStatus(), req.getWorkflowStatus())
                || !Objects.equals(quality.getQcLine(), req.getQcLine()) || isReturnQuality(quality)
                || !effectiveCodes.contains(quality.getCode())))
            throw new IllegalArgumentException("待质检记录状态或归属已变化,请重新扫描核对");
        applyRetestBatch(pending, req.getIndicators(), req.getReason());
@@ -486,29 +694,48 @@
    private void applyRetestBatch(List<MesQcSeedQualityDO> qualities,
                                  List<MesQcSeedQualitySubmitReqVO.Indicator> submittedIndicators, String reason) {
        boolean passed = validateIndicators(submittedIndicators);
        if (!passed && StrUtil.isBlank(reason)) throw new IllegalArgumentException("检验不合格必须填写原因");
        List<Long> ids = qualities.stream().map(MesQcSeedQualityDO::getId).toList();
        Map<Long, Map<String, MesQcSeedQualityIndicatorDO>> existingByQuality = new HashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicatorMapper.selectListByQualityIds(ids)) {
            existingByQuality.computeIfAbsent(indicator.getQualityId(), ignored -> new HashMap<>())
                    .put(indicator.getIndicatorCode(), indicator);
        }
        Map<String, MesQcSeedQualityIndicatorDO> firstIndicators = existingByQuality.get(qualities.get(0).getId());
        Map<String, IndicatorMeta> expected = toMetaMap(
                firstIndicators == null ? Collections.<MesQcSeedQualityIndicatorDO>emptyList() : firstIndicators.values(),
                qualities.get(0).getItemId());
        boolean passed = validateIndicators(submittedIndicators, expected);
        if (!passed && StrUtil.isBlank(reason)) throw new IllegalArgumentException("检验不合格必须填写原因");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        List<MesQcSeedQualityDO> qualityUpdates = new ArrayList<>(qualities.size());
        List<MesQcSeedQualityIndicatorDO> indicatorUpdates = new ArrayList<>(qualities.size() * REQUIRED.size());
        List<MesQcSeedQualityIndicatorDO> indicatorUpdates = new ArrayList<>(qualities.size() * submittedIndicators.size());
        List<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        Set<String> lineProcessTargets = new HashSet<>();
        List<MesQcSeedQualityDO> returnReady = new ArrayList<>();
        for (MesQcSeedQualityDO quality : qualities) {
            boolean returnQuality = Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
            boolean initialRecheck = Objects.equals(quality.getWorkflowStatus(), WAIT_INITIAL_RECHECK);
            if (!initialRecheck && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
            if (returnQuality && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            if (!returnQuality && !initialRecheck && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            int nextStatus;
            if (returnQuality) {
                nextStatus = WAIT_PROCESS;
                returnReady.add(quality);
            } else if (initialRecheck) {
                nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
                lineProcessTargets.add(quality.getQcLine() + "#" + quality.getBatchCode());
            } else {
                nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
                lineProcessTargets.add(quality.getQcLine() + "#" + quality.getBatchCode());
            }
            MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(quality.getId()).result(result)
                    .reason(passed ? "" : reason.trim()).inspectorId(operatorId).inspectedAt(now)
                    .workflowStatus(initialRecheck ? (passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW)
                            : (passed ? WAIT_COMPANY : WAIT_COMPANY_FAIL)).build();
                    .workflowStatus(nextStatus).build();
            if (!initialRecheck) {
                update.setRetestResult(result);
                update.setRetestedAt(now);
@@ -525,8 +752,14 @@
                    reason, submittedIndicators, operatorId, now));
        }
        qualityMapper.updateBatch(qualityUpdates, WRITE_BATCH_SIZE);
        indicatorMapper.updateBatch(indicatorUpdates, WRITE_BATCH_SIZE * REQUIRED.size());
        indicatorMapper.updateBatch(indicatorUpdates, WRITE_BATCH_SIZE * submittedIndicators.size());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
        // å¤æ£€æŒ‰è®°å½•所属质检线分别发起本线审批,避免提前触发另一条线。
        lineProcessTargets.forEach(target -> {
            String[] parts = target.split("#", 2);
            afterCommit(() -> startLineProcess(Integer.valueOf(parts[0]), parts[1], operatorId));
        });
        returnReady.forEach(quality -> afterCommit(() -> startReturnProcess(quality, operatorId)));
    }
    @Override @Transactional(rollbackFor = Exception.class)
@@ -534,28 +767,84 @@
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能进行初检审核");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        requireLegacyApproval(quality);
        applyProcessApproval(quality, approved, opinion, true);
    }
    /**
     * æœ¬çº¿é¢†å¯¼å®¡æ‰¹ï¼šç”Ÿäº§è´¨æ£€é€šè¿‡åŽè¿›å…¥å…¬å¸è´¨æ£€å®¡æ‰¹ï¼Œé©³å›žåŽé‡æ–°åˆæ£€ï¼›ç»é”€å•†é€€è´§è´¨æ£€åˆ™ç›´æŽ¥ç»ˆå®¡ã€‚
     */
    private void applyProcessApproval(MesQcSeedQualityDO quality, boolean approved, String opinion, boolean validateApprover) {
        if (validateApprover) {
            approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                    MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        }
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        int next = approved ? WAIT_RETEST : WAIT_INITIAL_RECHECK;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(id).workflowStatus(next)
                .processApprovedAt(LocalDateTime.now()).build());
        recordAction(id, approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), opinion, null);
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)) {
            if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
                throw new IllegalArgumentException("经销商退货质检当前状态不能进行销售部审批");
            applyReturnApproval(quality, approved, opinion, operatorId, now);
            return;
        }
        int next = approved ? (Objects.equals(quality.getQcLine(), 0) ? WAIT_RETEST
                : Objects.equals(quality.getResult(), 1) ? WAIT_COMPANY : WAIT_COMPANY_FAIL)
                : WAIT_INITIAL_RECHECK;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId()).workflowStatus(next)
                .processApprovedAt(now).build());
        if (!approved) qualityMapper.clearLineProcessInstance(quality.getId());
        actionMapper.insert(newAction(quality.getId(), approved ? "PROCESS_APPROVE" : "PROCESS_REJECT",
                quality.getResult(), opinion, null, operatorId, now));
        if (approved && !Objects.equals(quality.getQcLine(), 0))
            afterCommit(() -> maybeStartCompanyProcess(quality.getBatchCode(), operatorId));
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public void companyApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
        if (quality == null) throw new IllegalArgumentException("质检记录不存在");
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
            throw new IllegalArgumentException("经销商退货质检请使用销售部审批");
        if (!Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能进行复检审核");
        requireLegacyApproval(quality);
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.COMPANY.getNode(), getLoginUserId());
        applyCompanyApproval(quality.getCode(), approved, opinion, getLoginUserId());
    }
    /**
     * å…¬å¸è´¨æ£€å®¡æ‰¹ï¼šä»¥ã€Œè¢‹ã€ä¸ºå•位成对审批,两条质检线都走完才允许审批。
     * ä¸¤æ¡çº¿å‡åˆæ ¼æ‰æ”¾è¡Œå…¥åº“,任一条线不合格则整袋报废;驳回则两条线全部退回复检。
     */
    private void applyCompanyApproval(String code, boolean approved, String opinion, Long operatorId) {
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        if (approved) finalizeStock(quality, quality.getWorkflowStatus() == WAIT_COMPANY);
        int next = approved ? (quality.getWorkflowStatus() == WAIT_COMPANY ? RELEASED : SCRAPPED) : WAIT_RETEST;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(id).workflowStatus(next)
                .companyApprovedAt(LocalDateTime.now()).build());
        recordAction(id, approved ? "COMPANY_APPROVE" : "COMPANY_REJECT", quality.getResult(), opinion, null);
        List<MesQcSeedQualityDO> pair = qualityMapper.selectListByCodeForUpdate(code).stream()
                .filter(q -> !isReturnQuality(q)).toList();
        if (pair.size() != 2 || pair.stream().anyMatch(q -> !Objects.equals(q.getQcLine(), QC_LINE_PROCESS)
                && !Objects.equals(q.getQcLine(), QC_LINE_SALES)))
            throw new IllegalArgumentException("袋码 " + code + " çš„双线质检记录不完整,无法公司审批");
        if (pair.stream().anyMatch(q -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(q.getWorkflowStatus())))
            throw new IllegalArgumentException("袋码 " + code + " çš„双线质检尚未全部完成,不能进行公司质检审批");
        boolean passed = pair.stream().allMatch(q -> Objects.equals(q.getWorkflowStatus(), WAIT_COMPANY));
        MesQcSeedQualityDO representative = pair.stream()
                .filter(q -> Objects.equals(q.getQcLine(), QC_LINE_PROCESS)).findFirst().orElse(pair.get(0));
        LocalDateTime now = LocalDateTime.now();
        if (approved) {
            finalizeStock(representative, passed);
            qualityMapper.updateBatch(pair.stream().map(quality -> MesQcSeedQualityDO.builder()
                    .id(quality.getId()).workflowStatus(passed ? RELEASED : SCRAPPED)
                    .companyApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        } else {
            qualityMapper.clearCompanyProcessInstance(code);
            pair.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
            qualityMapper.updateBatch(pair.stream().map(quality -> MesQcSeedQualityDO.builder()
                    .id(quality.getId()).workflowStatus(WAIT_RETEST).companyApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        }
        actionMapper.insertBatch(pair.stream().map(quality -> newAction(quality.getId(),
                approved ? "COMPANY_APPROVE" : "COMPANY_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
                WRITE_BATCH_SIZE);
    }
    @Override @Transactional(rollbackFor = Exception.class)
@@ -563,6 +852,7 @@
        List<Long> confirmed = approvalIds(ids);
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, qualities);
        qualities.forEach(MesQcSeedQualityServiceImpl::requireLegacyApproval);
        if (qualities.stream().anyMatch(quality -> !Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus())))
            throw new IllegalArgumentException("所选记录包含不能进行初检审核的状态,请刷新后重试");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
@@ -570,10 +860,17 @@
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        List<MesQcSeedQualityDO> production = qualities.stream()
                .filter(quality -> !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)).toList();
        qualities.stream().filter(quality -> Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
                .forEach(quality -> applyProcessApproval(quality, approved, opinion, false));
        if (production.isEmpty()) return;
        int next = approved ? WAIT_RETEST : WAIT_INITIAL_RECHECK;
        qualityMapper.updateBatch(qualities.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(next).processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        actionMapper.insertBatch(qualities.stream().map(quality -> newAction(quality.getId(),
        qualityMapper.updateBatch(production.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(next)
                .processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        if (!approved) production.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
        actionMapper.insertBatch(production.stream().map(quality -> newAction(quality.getId(),
                approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
                WRITE_BATCH_SIZE);
    }
@@ -583,23 +880,340 @@
        List<Long> confirmed = approvalIds(ids);
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, qualities);
        if (qualities.stream().anyMatch(quality -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus())))
            throw new IllegalArgumentException("所选记录包含不能进行复检审核的状态,请刷新后重试");
        qualities.forEach(MesQcSeedQualityServiceImpl::requireLegacyApproval);
        if (qualities.stream().anyMatch(quality -> Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)))
            throw new IllegalArgumentException("经销商退货质检请使用销售部审批");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.COMPANY.getNode(), getLoginUserId());
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        if (approved) {
            for (MesQcSeedQualityDO quality : qualities) finalizeStock(quality, quality.getWorkflowStatus() == WAIT_COMPANY);
        }
        Long operatorId = getLoginUserId();
        // å…¬å¸å®¡æ‰¹ä»¥è¢‹ä¸ºå•位成对处理,同一袋的两条线记录一次审批
        qualities.stream().map(MesQcSeedQualityDO::getCode).distinct().sorted()
                .forEach(code -> applyCompanyApproval(code, approved, opinion, operatorId));
    }
    // ==================== ç»é”€å•†é€€è´§è´¨æ£€ ====================
    @Override
    public Map<String, Object> resolveReturnQuality(Long returnSalesId) {
        MesWmReturnSalesDO returnSales = returnSalesService.getReturnSales(returnSalesId);
        if (returnSales == null) throw new IllegalArgumentException("销售退货单不存在");
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(returnSalesId);
        MesWmReturnSalesLineDO first = lines.stream().filter(line -> line.getItemId() != null).findFirst().orElse(null);
        Long itemId = first == null ? null : first.getItemId();
        MesQcSeedQualityDO quality = qualityMapper.selectByReturnSalesId(returnSalesId);
        Map<String, Object> result = new HashMap<>();
        result.put("returnSalesId", returnSales.getId());
        result.put("returnSalesCode", returnSales.getCode());
        result.put("returnSalesStatus", returnSales.getStatus());
        result.put("itemId", itemId);
        result.put("batchId", first == null ? null : first.getBatchId());
        result.put("batchCode", first == null ? null : first.getBatchCode());
        result.put("indicators", toRangePayload(itemId));
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        result.put("workflowStatus", quality == null ? null : quality.getWorkflowStatus());
        return result;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long submitReturnQuality(MesQcSeedReturnQualitySubmitReqVO reqVO) {
        MesWmReturnSalesDO returnSales = returnSalesService.getReturnSales(reqVO.getReturnSalesId());
        if (returnSales == null) throw new IllegalArgumentException("销售退货单不存在");
        if (qualityMapper.selectByReturnSalesId(returnSales.getId()) != null)
            throw new IllegalArgumentException("该销售退货单已完成退货质检,禁止重复提交");
        if (!Set.of(MesWmReturnSalesStatusEnum.PREPARE.getStatus(),
                MesWmReturnSalesStatusEnum.CONFIRMED.getStatus()).contains(returnSales.getStatus()))
            throw new IllegalArgumentException("当前销售退货单状态不能进行退货质检");
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(returnSales.getId());
        if (CollUtil.isEmpty(lines)) throw new IllegalArgumentException("销售退货单没有明细,无法质检");
        MesWmReturnSalesLineDO first = lines.stream().filter(line -> line.getItemId() != null).findFirst()
                .orElseThrow(() -> new IllegalArgumentException("销售退货单明细缺少物料,无法质检"));
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(first.getItemId());
        boolean passed = validateIndicators(reqVO.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        List<MesQcSeedQualityDO> updates = qualities.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(approved ? (quality.getWorkflowStatus() == WAIT_COMPANY ? RELEASED : SCRAPPED) : WAIT_RETEST)
                .companyApprovedAt(now).build()).toList();
        qualityMapper.updateBatch(updates, WRITE_BATCH_SIZE);
        actionMapper.insertBatch(qualities.stream().map(quality -> newAction(quality.getId(),
                approved ? "COMPANY_APPROVE" : "COMPANY_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
        int result = passed ? 1 : 2;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder()
                .code(returnSales.getCode()).qcLine(QC_LINE_SALES).bizType(BIZ_TYPE_RETURN)
                .itemId(first.getItemId()).batchId(first.getBatchId()).batchCode(first.getBatchCode())
                .result(result).reason(passed ? "" : reqVO.getReason().trim())
                // åˆæ£€åˆæ ¼è¿›å…¥é”€å”®éƒ¨å®¡æ‰¹ï¼›ä¸åˆæ ¼å…ˆå¤æ£€ã€‚
                .workflowStatus(passed ? WAIT_PROCESS : WAIT_RETEST)
                .returnSalesId(returnSales.getId()).returnSalesCode(returnSales.getCode())
                .inspectorId(operatorId).inspectedAt(now).build();
        qualityMapper.insert(quality);
        for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
            IndicatorMeta meta = expected.get(item.getCode());
            indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId())
                    .indicatorCode(item.getCode()).indicatorName(meta.name()).unit(meta.unit())
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
        recordAction(quality.getId(), "INITIAL", result, reqVO.getReason(), reqVO.getIndicators());
        returnSalesService.updateReturnSalesStatus(returnSales.getId(), MesWmReturnSalesStatusEnum.CONFIRMED.getStatus());
        if (passed) afterCommit(() -> startReturnProcess(quality, operatorId));
        return quality.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void returnApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
            throw new IllegalArgumentException("质检记录不存在或不是经销商退货质检");
        requireLegacyApproval(quality);
        if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
            throw new IllegalArgumentException("当前状态不能进行销售部审批");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        applyReturnApproval(quality, approved, opinion, getLoginUserId(), LocalDateTime.now());
    }
    /**
     * é”€å”®éƒ¨é¢†å¯¼å®¡æ‰¹ï¼šé€šè¿‡åˆ™é€€è´§å•进入执行;不通过则不予退货并取消退货单,质检记录留存。
     */
    private void applyReturnApproval(MesQcSeedQualityDO quality, boolean approved, String opinion,
                                     Long operatorId, LocalDateTime now) {
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        if (approved && Objects.equals(quality.getResult(), 1)) {
            returnSalesService.updateReturnSalesStatus(quality.getReturnSalesId(),
                    MesWmReturnSalesStatusEnum.APPROVING.getStatus());
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .workflowStatus(RELEASED).processApprovedAt(now).build());
        } else {
            cancelReturnSalesQuietly(quality.getReturnSalesId());
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .workflowStatus(SCRAPPED).processApprovedAt(now).build());
        }
        actionMapper.insert(newAction(quality.getId(), approved ? "RETURN_APPROVE" : "RETURN_REJECT",
                quality.getResult(), opinion, null, operatorId, now));
    }
    // ==================== BPM æµç¨‹å›žè°ƒ ====================
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleLineProcessResult(String processInstanceId, Integer qcLine, String batchCode, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId) || qcLine == null || StrUtil.isBlank(batchCode)) return;
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByProcessInstanceId(processInstanceId).stream()
                .filter(quality -> Objects.equals(quality.getQcLine(), qcLine)
                        && Objects.equals(quality.getBatchCode(), batchCode)
                        && !isReturnQuality(quality)
                        && Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) {
            log.info("[handleLineProcessResult][批次 {} è´¨æ£€çº¿ {} æ— å¾…审批记录,忽略 BPM äº‹ä»¶]", batchCode, qcLine);
            return;
        }
        List<Long> ids = pending.stream().map(MesQcSeedQualityDO::getId).sorted().toList();
        List<MesQcSeedQualityDO> locked = qualityMapper.selectListByIdsForUpdate(ids);
        validateLockedIds(ids, locked);
        LocalDateTime now = LocalDateTime.now();
        int next = approved ? WAIT_COMPANY : WAIT_INITIAL_RECHECK;
        String finalReason = StrUtil.blankToDefault(reason, approved ? "审批通过" : "审批驳回");
        qualityMapper.updateBatch(locked.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(approved && Objects.equals(quality.getResult(), 2) ? WAIT_COMPANY_FAIL : next)
                .processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        if (!approved) locked.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
        actionMapper.insertBatch(locked.stream().map(quality -> newAction(quality.getId(),
                approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), finalReason, null, null, now)).toList(),
                WRITE_BATCH_SIZE);
        if (approved) {
            Long inspectorId = locked.stream().map(MesQcSeedQualityDO::getInspectorId)
                    .filter(Objects::nonNull).findFirst().orElse(null);
            afterCommit(() -> maybeStartCompanyProcess(batchCode, inspectorId));
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleCompanyProcessResult(String processInstanceId, String businessKey, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId)) return;
        // å…¬å¸å®¡æ‰¹æŒ‰ç”Ÿäº§æ‰¹æ¬¡å‘起,一个流程实例覆盖该批次的所有袋;
        // æŒ‰æµç¨‹å®žä¾‹ç¼–号回查可同时兼容历史按袋码发起的审批。
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByCompanyProcessInstanceId(processInstanceId).stream()
                .filter(quality -> !isReturnQuality(quality)
                        && Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) {
            log.info("[handleCompanyProcessResult][流程 {} æ— å¾…公司审批记录,忽略 BPM äº‹ä»¶]", processInstanceId);
            return;
        }
        String finalReason = StrUtil.blankToDefault(reason, approved ? "公司质检审批通过" : "公司质检审批驳回");
        // æ”¾è¡Œ/报废以袋为单位落库,同一批次的多个袋依次处理
        pending.stream().map(MesQcSeedQualityDO::getCode).distinct().sorted()
                .forEach(code -> applyCompanyApproval(code, approved, finalReason, null));
        log.info("[handleCompanyProcessResult][公司质检审批 {} å®Œæˆï¼Œæ‰¹æ¬¡/袋码 {} å…±å¤„理 {} æ¡è®°å½•]",
                processInstanceId, businessKey, pending.size());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleReturnProcessResult(String processInstanceId, Long returnSalesId, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId) || returnSalesId == null) return;
        MesQcSeedQualityDO quality = qualityMapper.selectByReturnSalesIdForUpdate(returnSalesId);
        if (quality == null || !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)
                || !Objects.equals(quality.getProcessInstanceId(), processInstanceId)
                || !Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS)) {
            log.info("[handleReturnProcessResult][销售退货单 {} æ— å¾…审批退货质检,忽略 BPM äº‹ä»¶]", returnSalesId);
            return;
        }
        applyReturnApproval(quality, approved, StrUtil.blankToDefault(reason, approved ? "审批通过" : "审批驳回"),
                null, LocalDateTime.now());
    }
    private void startLineProcess(Integer qcLine, String batchCode, Long operatorId) {
        if (qcLine == null || StrUtil.isBlank(batchCode) || operatorId == null) return;
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByBatchCode(batchCode).stream()
                .filter(quality -> Objects.equals(quality.getQcLine(), qcLine)
                        && !isReturnQuality(quality)
                        && StrUtil.isBlank(quality.getProcessInstanceId())
                        && Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("qcLine", qcLine);
        variables.put("batchCode", batchCode);
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(LINE_PROCESS_KEY)
                        .setBusinessKey(qcLine + "#" + batchCode)
                        .setVariables(variables));
        for (MesQcSeedQualityDO quality : pending) {
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .processInstanceId(processInstanceId).build());
        }
        log.info("[startLineProcess][批次 {} è´¨æ£€çº¿ {} å·²å‘起本线领导审批 {}", batchCode, qcLine, processInstanceId);
    }
    /**
     * åŒçº¿éƒ¨é—¨å®¡æ‰¹å…¨éƒ¨å®ŒæˆåŽï¼ŒæŒ‰ç”Ÿäº§æ‰¹æ¬¡ç»Ÿä¸€å‘起一次公司质检审批。
     * åŒä¸€æ‰¹æ¬¡çš„多袋共用同一个流程实例,避免每袋各发一条审批。
     */
    private void maybeStartCompanyProcess(String batchCode, Long operatorId) {
        if (StrUtil.isBlank(batchCode) || operatorId == null) return;
        List<MesQcSeedQualityDO> records = qualityMapper.selectListByBatchCode(batchCode).stream()
                .filter(quality -> !isReturnQuality(quality))
                // å·²æ”¾è¡Œ/已报废的记录属于已终结的批次,不再参与新的公司审批
                .filter(quality -> !Set.of(RELEASED, SCRAPPED).contains(quality.getWorkflowStatus()))
                .toList();
        if (records.isEmpty()) return;
        if (records.stream().anyMatch(quality -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL)
                .contains(quality.getWorkflowStatus()))) return;
        if (records.stream().anyMatch(quality -> StrUtil.isNotBlank(quality.getCompanyProcessInstanceId()))) return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("batchCode", batchCode);
        // å…¼å®¹æµç¨‹æ¡ä»¶ä¸­å¼•用的 code å˜é‡
        variables.put("code", batchCode);
        variables.put("passed", records.stream().allMatch(quality -> Objects.equals(quality.getWorkflowStatus(), WAIT_COMPANY)));
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(COMPANY_PROCESS_KEY)
                        .setBusinessKey(batchCode)
                        .setVariables(variables));
        for (MesQcSeedQualityDO quality : records) {
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .companyProcessInstanceId(processInstanceId).build());
        }
        log.info("[maybeStartCompanyProcess][批次 {} åŒçº¿è´¨æ£€å®Œæˆï¼Œå·²å‘起公司质检审批 {},覆盖 {} æ¡è®°å½•]",
                batchCode, processInstanceId, records.size());
    }
    private void startReturnProcess(MesQcSeedQualityDO quality, Long operatorId) {
        if (quality == null || quality.getReturnSalesId() == null || StrUtil.isNotBlank(quality.getProcessInstanceId()))
            return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("returnSalesId", quality.getReturnSalesId());
        variables.put("returnSalesCode", quality.getReturnSalesCode());
        variables.put("code", quality.getCode());
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(RETURN_PROCESS_KEY)
                        .setBusinessKey(String.valueOf(quality.getReturnSalesId()))
                        .setVariables(variables));
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                .processInstanceId(processInstanceId).build());
        log.info("[startReturnProcess][销售退货单 {} å·²å‘起销售部领导审批 {}]", quality.getReturnSalesId(), processInstanceId);
    }
    @Override
    public void retryBpmProcess(Long id) {
        MesQcSeedQualityDO quality = qualityMapper.selectById(id);
        if (quality == null) throw invalidParamException("质检记录不存在");
        Long operatorId = getLoginUserId();
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)) {
            if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
                throw invalidParamException("当前退货质检不需要发起审批");
            if (StrUtil.isNotBlank(quality.getProcessInstanceId()))
                throw invalidParamException("退货质检审批流程已经发起");
            startReturnProcess(quality, operatorId);
        } else if (Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus())) {
            if (StrUtil.isNotBlank(quality.getProcessInstanceId()))
                throw invalidParamException("部门审批流程已经发起");
            startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId);
        } else if (Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus())) {
            String batchCode = quality.getBatchCode();
            if (StrUtil.isBlank(batchCode))
                throw invalidParamException("质检记录未绑定生产批次,不能发起公司审批");
            List<MesQcSeedQualityDO> batchRecords = qualityMapper.selectListByBatchCode(batchCode).stream()
                    .filter(q -> !isReturnQuality(q))
                    .filter(q -> !Set.of(RELEASED, SCRAPPED).contains(q.getWorkflowStatus()))
                    .toList();
            if (batchRecords.isEmpty() || batchRecords.stream().anyMatch(q -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL)
                    .contains(q.getWorkflowStatus())))
                throw invalidParamException("同批次双线部门审批尚未全部完成,不能发起公司审批");
            if (batchRecords.stream().anyMatch(q -> StrUtil.isNotBlank(q.getCompanyProcessInstanceId())))
                throw invalidParamException("公司质检审批流程已经发起");
            maybeStartCompanyProcess(batchCode, operatorId);
        } else {
            throw invalidParamException("当前质检状态不需要发起审批");
        }
    }
    private void cancelReturnSalesQuietly(Long returnSalesId) {
        if (returnSalesId == null) return;
        try {
            returnSalesService.cancelReturnSales(returnSalesId);
        } catch (Exception ex) {
            log.warn("[cancelReturnSalesQuietly][销售退货单 {} å–消失败: {}]", returnSalesId, ex.getMessage());
        }
    }
    private void afterCommit(Runnable action) {
        TransactionSynchronization synchronization = new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                try {
                    action.run();
                } catch (Exception ex) {
                    log.error("[afterCommit][BPM æµç¨‹å¯åŠ¨å¤±è´¥ï¼Œè¯·ä¿®å¤æµç¨‹é…ç½®å¹¶åœ¨è´¨æ£€è®°å½•ä¸­é‡æ–°å‘èµ·å®¡æ‰¹]", ex);
                }
            }
        };
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(synchronization);
        } else {
            synchronization.afterCommit();
        }
    }
    /** åŽ†å²æ•°æ® bizType ä¸ºç©ºï¼ŒæŒ‰ç”Ÿäº§å…¥åº“质检处理 */
    private static void requireQcLine(Integer qcLine) {
        if (!Objects.equals(qcLine, QC_LINE_PROCESS) && !Objects.equals(qcLine, QC_LINE_SALES))
            throw new IllegalArgumentException("质检线必须为加工部或销售部");
    }
    /** ç”Ÿäº§åŒçº¿è®°å½•只能走 BPM å®¡æ‰¹ï¼›ç»é”€å•†é€€è´§è´¨æ£€ä¸æ‹†åˆ†è´¨æ£€çº¿ï¼Œä»å¯ä½¿ç”¨äººå·¥å®¡æ‰¹æŽ¥å£ã€‚ */
    private static void requireLegacyApproval(MesQcSeedQualityDO quality) {
        if (!isReturnQuality(quality) && (Objects.equals(quality.getQcLine(), QC_LINE_PROCESS)
                || Objects.equals(quality.getQcLine(), QC_LINE_SALES)))
            throw new IllegalArgumentException("该质检记录由 BPM å®¡æ‰¹ï¼Œè¯·åœ¨æµç¨‹å¾…办中处理");
    }
    private static boolean isReturnQuality(MesQcSeedQualityDO quality) {
        return Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
    }
    private List<Long> approvalIds(List<Long> ids) {
@@ -626,21 +1240,95 @@
        return quality;
    }
    private boolean validateIndicators(List<MesQcSeedQualitySubmitReqVO.Indicator> indicators) {
        if (indicators == null || indicators.size() != REQUIRED.size()) throw new IllegalArgumentException("必须提交四项指标");
    private record IndicatorMeta(String code, String name, String unit) { }
    private Map<String, IndicatorMeta> requireEffectiveIndicators(Long itemId) {
        Map<String, IndicatorMeta> result = new LinkedHashMap<>();
        for (MesQcSeedIndicatorRangeDO range : rangeService.getEffectiveRanges(itemId)) {
            result.put(range.getIndicatorCode(), new IndicatorMeta(range.getIndicatorCode(),
                    StrUtil.blankToDefault(range.getIndicatorName(), range.getIndicatorCode()),
                    StrUtil.blankToDefault(range.getUnit(), "%")));
        }
        if (result.isEmpty())
            throw new IllegalArgumentException("该产品尚未配置质检项,请先在「质检项合格范围配置」中维护");
        return result;
    }
    private Map<String, IndicatorMeta> toMetaMap(Collection<MesQcSeedQualityIndicatorDO> indicators, Long itemId) {
        Map<String, MesQcSeedIndicatorRangeDO> config = rangeService.getEffectiveMap(itemId);
        Map<String, IndicatorMeta> result = new LinkedHashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicators) {
            MesQcSeedIndicatorRangeDO range = config.get(indicator.getIndicatorCode());
            String name = StrUtil.blankToDefault(indicator.getIndicatorName(),
                    range == null ? indicator.getIndicatorCode() : range.getIndicatorName());
            String unit = StrUtil.blankToDefault(indicator.getUnit(), range == null ? "%" : range.getUnit());
            result.put(indicator.getIndicatorCode(), new IndicatorMeta(indicator.getIndicatorCode(), name, unit));
        }
        return result;
    }
    private boolean validateIndicators(List<MesQcSeedQualitySubmitReqVO.Indicator> indicators,
                                       Map<String, IndicatorMeta> expected) {
        if (indicators == null || indicators.isEmpty()) throw new IllegalArgumentException("请至少提交一项质检指标");
        if (expected.isEmpty()) throw new IllegalArgumentException("该产品尚未配置质检项");
        if (indicators.size() != expected.size())
            throw new IllegalArgumentException("质检项数量与产品配置不一致,请刷新后重新录入");
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        for (MesQcSeedQualitySubmitReqVO.Indicator item : indicators) {
            if (item == null || !REQUIRED.contains(item.getCode()) || !codes.add(item.getCode())
            if (item == null || StrUtil.isBlank(item.getCode()) || !codes.add(item.getCode())
                    || item.getValue() == null || item.getMinValue() == null || item.getMaxValue() == null)
                throw new IllegalArgumentException("指标必须为纯度、净度、发芽率、水分且不可重复");
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("指标下限不能大于上限");
            if (item.getValue().compareTo(BigDecimal.ZERO) < 0 || item.getValue().compareTo(new BigDecimal("100")) > 0
                    || item.getMinValue().compareTo(BigDecimal.ZERO) < 0 || item.getMaxValue().compareTo(new BigDecimal("100")) > 0)
                throw new IllegalArgumentException("百分比指标必须处于 0–100");
                throw new IllegalArgumentException("质检项的实测值和合格范围必须完整且不可重复");
            if (!expected.containsKey(item.getCode()))
                throw new IllegalArgumentException("质检项未在产品配置中启用:" + item.getCode());
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("质检项下限不能大于上限");
            if (item.getValue().compareTo(BigDecimal.ZERO) < 0 || item.getMinValue().compareTo(BigDecimal.ZERO) < 0
                    || item.getMaxValue().compareTo(BigDecimal.ZERO) < 0)
                throw new IllegalArgumentException("质检项数值不能为负数");
            if (item.getValue().compareTo(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
        }
        if (!codes.equals(expected.keySet())) throw new IllegalArgumentException("产品质检项不完整,请刷新后重新录入");
        return passed;
    }
    private Long resolveItemId(Scope scope) {
        Long itemId = scope.bags().stream().map(MesProBagCodeDO::getItemId).filter(Objects::nonNull).findFirst().orElse(null);
        if (itemId != null || scope.bags().isEmpty()) return itemId;
        Long batchId = scope.bags().get(0).getBatchId();
        if (batchId == null) return null;
        MesProBatchDO batch = proBatchService.getBatch(batchId);
        return batch == null ? null : batch.getItemId();
    }
    private List<Map<String, Object>> toRangePayload(Long itemId) {
        List<Map<String, Object>> result = new ArrayList<>();
        for (MesQcSeedIndicatorRangeDO range : rangeService.getEffectiveRanges(itemId)) {
            Map<String, Object> item = new HashMap<>();
            item.put("code", range.getIndicatorCode());
            item.put("name", StrUtil.blankToDefault(range.getIndicatorName(), range.getIndicatorCode()));
            item.put("unit", StrUtil.blankToDefault(range.getUnit(), "%"));
            item.put("minValue", range.getMinValue());
            item.put("maxValue", range.getMaxValue());
            result.add(item);
        }
        return result;
    }
    private List<Map<String, Object>> toIndicatorPayload(List<MesQcSeedQualityIndicatorDO> indicators, Long itemId) {
        Map<String, MesQcSeedIndicatorRangeDO> config = rangeService.getEffectiveMap(itemId);
        List<Map<String, Object>> result = new ArrayList<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicators) {
            MesQcSeedIndicatorRangeDO range = config.get(indicator.getIndicatorCode());
            Map<String, Object> item = new HashMap<>();
            item.put("code", indicator.getIndicatorCode());
            item.put("name", StrUtil.blankToDefault(indicator.getIndicatorName(),
                    range == null ? indicator.getIndicatorCode() : range.getIndicatorName()));
            item.put("unit", StrUtil.blankToDefault(indicator.getUnit(), range == null ? "%" : range.getUnit()));
            item.put("minValue", indicator.getMinValue());
            item.put("maxValue", indicator.getMaxValue());
            result.add(item);
        }
        return result;
    }
    private void recordAction(Long id, String action, Integer result, String opinion,
@@ -711,20 +1399,20 @@
    /**
     * è§£æžåˆæ ¼åº“的默认入库库区与库位:取第一个同时配置了库位的非冻结库区。
     *
     * è´¨æ£€æŒ‰ä»“库维度选择入库目标,库区/库位由系统自动落点;合格库缺少可用库区或库位时,
     * åº“存事务无法写入,直接提示管理员完善仓库配置。
     * åº“区/库位为可选项,仓库未配置时按仓库层级直接入库,返回 null。
     */
    private Long[] resolveQualifiedLocation(MesWmWarehouseDO qualifiedWarehouse) {
        List<MesWmWarehouseLocationDO> locations = warehouseLocationService.getWarehouseLocationList(qualifiedWarehouse.getId())
                .stream().filter(location -> !Boolean.TRUE.equals(location.getFrozen())).toList();
        if (CollUtil.isEmpty(locations))
            throw new IllegalArgumentException("合格库「" + qualifiedWarehouse.getName() + "」未配置可用库区,请先在仓库管理中完善库区与库位");
        if (CollUtil.isEmpty(locations)) {
            return new Long[]{null, null};
        }
        for (MesWmWarehouseLocationDO location : locations) {
            List<MesWmWarehouseAreaDO> areas = warehouseAreaService.getWarehouseAreaList(location.getId())
                    .stream().filter(area -> !Boolean.TRUE.equals(area.getFrozen())).toList();
            if (CollUtil.isNotEmpty(areas)) return new Long[]{location.getId(), areas.get(0).getId()};
        }
        throw new IllegalArgumentException("合格库「" + qualifiedWarehouse.getName() + "」未配置可用库位,请先在仓库管理中完善库区与库位");
        return new Long[]{null, null};
    }
    private MesWmMaterialStockDO getOrCreateQualifiedStock(MesWmMaterialStockDO tempStock, Long qualifiedWarehouseId,
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/listener/MesQcSeedCompanyStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.mes.service.qc.seedquality.MesQcSeedQualityService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * ç§å­è´¨æ£€å…¬å¸è´¨æ£€å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
 * <p>流程定义 key:mes_seed_quality_company_approve;businessKey ä¸ºç”Ÿäº§æ‰¹æ¬¡å·ã€‚
 * è¯¥æ‰¹æ¬¡åŒçº¿è´¨æ£€å…¨éƒ¨å®ŒæˆåŽåªå‘起一次该流程,审批完成后按每个袋的两条线结果统一放行或报废。
 */
@Component
@Slf4j
public class MesQcSeedCompanyStatusListener extends BpmProcessInstanceStatusEventListener {
    private static final String BPM_PROCESS_DEFINITION_KEY = "mes_seed_quality_company_approve";
    @Resource
    private MesQcSeedQualityService seedQualityService;
    @Override
    protected String getProcessDefinitionKey() {
        return BPM_PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        if (!BpmProcessInstanceStatusEnum.isProcessEndStatus(event.getStatus())
                || BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(event.getStatus())) {
            return;
        }
        String businessKey = event.getBusinessKey();
        if (businessKey == null || businessKey.isBlank()) {
            log.warn("[onEvent][公司质检审批 businessKey ä¸ºç©ºï¼Œå¿½ç•¥]");
            return;
        }
        boolean approved = BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(event.getStatus());
        log.info("[onEvent][公司质检审批完成,批次={} ç»“æžœ={}]", businessKey, event.getStatus());
        seedQualityService.handleCompanyProcessResult(event.getId(), businessKey, approved, event.getReason());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/listener/MesQcSeedLineStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.mes.service.qc.seedquality.MesQcSeedQualityService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * ç§å­è´¨æ£€æœ¬çº¿é¢†å¯¼å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
 * <p>流程定义 key:mes_seed_quality_line_approve;businessKey æ ¼å¼ï¼š{质检线}#{生产批次号}。
 * å®¡æ‰¹é€šè¿‡åŽæœ¬çº¿è¿›å…¥å¤æ£€ï¼Œé©³å›žåŽæœ¬çº¿é‡æ–°åˆæ£€ã€‚
 */
@Component
@Slf4j
public class MesQcSeedLineStatusListener extends BpmProcessInstanceStatusEventListener {
    private static final String BPM_PROCESS_DEFINITION_KEY = "mes_seed_quality_line_approve";
    @Resource
    private MesQcSeedQualityService seedQualityService;
    @Override
    protected String getProcessDefinitionKey() {
        return BPM_PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        if (!BpmProcessInstanceStatusEnum.isProcessEndStatus(event.getStatus())
                || BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(event.getStatus())) {
            return;
        }
        String businessKey = event.getBusinessKey();
        int index = businessKey == null ? -1 : businessKey.indexOf('#');
        if (index <= 0 || index == businessKey.length() - 1) {
            log.warn("[onEvent][本线审批 businessKey({}) æ ¼å¼æ— æ•ˆï¼Œåº”为 è´¨æ£€çº¿#批次号]", businessKey);
            return;
        }
        Integer qcLine;
        try {
            qcLine = Integer.valueOf(businessKey.substring(0, index));
        } catch (NumberFormatException ex) {
            log.warn("[onEvent][本线审批 businessKey({}) è´¨æ£€çº¿æ— æ•ˆ]", businessKey);
            return;
        }
        String batchCode = businessKey.substring(index + 1);
        boolean approved = BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(event.getStatus());
        log.info("[onEvent][本线审批完成,批次={} è´¨æ£€çº¿={} ç»“æžœ={}]", batchCode, qcLine, event.getStatus());
        seedQualityService.handleLineProcessResult(event.getId(), qcLine, batchCode, approved, event.getReason());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/listener/MesQcSeedReturnStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.mes.service.qc.seedquality.MesQcSeedQualityService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * ç»é”€å•†é€€è´§ç§å­è´¨æ£€é”€å”®éƒ¨å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
 * <p>流程定义 key:mes_seed_return_approve;businessKey ä¸ºé”€å”®é€€è´§å•编号。
 */
@Component
@Slf4j
public class MesQcSeedReturnStatusListener extends BpmProcessInstanceStatusEventListener {
    private static final String BPM_PROCESS_DEFINITION_KEY = "mes_seed_return_approve";
    @Resource
    private MesQcSeedQualityService seedQualityService;
    @Override
    protected String getProcessDefinitionKey() {
        return BPM_PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        if (!BpmProcessInstanceStatusEnum.isProcessEndStatus(event.getStatus())
                || BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(event.getStatus())) {
            return;
        }
        Long returnSalesId;
        try {
            returnSalesId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException ex) {
            log.warn("[onEvent][退货质检审批 businessKey({}) ä¸æ˜¯æœ‰æ•ˆçš„销售退货单编号]", event.getBusinessKey());
            return;
        }
        boolean approved = BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(event.getStatus());
        log.info("[onEvent][退货质检审批完成,销售退货单={} ç»“æžœ={}]", returnSalesId, event.getStatus());
        seedQualityService.handleReturnProcessResult(event.getId(), returnSalesId, approved, event.getReason());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/trace/MesProTracePublicServiceImpl.java
@@ -372,16 +372,22 @@
    }
    private MesProTracePublicRespVO.QualityVO buildQuality(MesProBatchDO batch) {
        List<MesQcSeedQualityDO> list = qcSeedQualityService.getQualityByBatchCode(batch.getCode());
        // åŒçº¿è´¨æ£€ï¼šåŒä¸€è¢‹å­˜åœ¨åŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•ï¼Œå…¨éƒ¨æ”¾è¡Œæ‰ç®—åˆæ ¼ï¼Œä»»ä¸€æ¡æŠ¥åºŸå³ä¸åˆæ ¼
        List<MesQcSeedQualityDO> list = qcSeedQualityService.getQualityByBatchCode(batch.getCode()).stream()
                .filter(quality -> quality.getBizType() == null || Integer.valueOf(1).equals(quality.getBizType()))
                .toList();
        if (CollUtil.isEmpty(list)) {
            return null;
        }
        MesQcSeedQualityDO quality = list.get(0);
        boolean released = list.stream().allMatch(item -> Integer.valueOf(40).equals(item.getWorkflowStatus()));
        boolean scrapped = list.stream().anyMatch(item -> Integer.valueOf(70).equals(item.getWorkflowStatus()));
        MesProTracePublicRespVO.QualityVO vo = new MesProTracePublicRespVO.QualityVO();
        Integer finalResult = Integer.valueOf(40).equals(quality.getWorkflowStatus()) ? 1
                : Integer.valueOf(70).equals(quality.getWorkflowStatus()) ? 2 : 0;
        Integer finalResult = scrapped ? 2 : released ? 1 : 0;
        String reason = list.stream().map(MesQcSeedQualityDO::getReason)
                .filter(StrUtil::isNotBlank).findFirst().orElse(quality.getReason());
        vo.setResult(finalResult).setResultName(finalResult == 0 ? "复检或审批中" : qualityResultName(finalResult))
                .setReason(quality.getReason()).setInspectorName(userName(quality.getInspectorId()))
                .setReason(reason).setInspectorName(userName(quality.getInspectorId()))
                .setInspectedAt(quality.getInspectedAt());
        List<MesQcSeedQualityIndicatorDO> indicators = qcSeedQualityService.getIndicatorsByQualityId(quality.getId());
        vo.setIndicators(convertList(indicators, ind -> {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/materialstock/MesWmMaterialStockServiceImpl.java
@@ -347,8 +347,11 @@
    @Override
    public void checkAreaMixingRule(Long areaId, Long itemId, Long batchId) {
        // 1.1 æ ¡éªŒåº“位必须存在
        // 1.1 æ ¡éªŒåº“位必须存在(未使用库位时跳过混放校验)
        MesWmWarehouseAreaDO area = areaService.validateWarehouseAreaExists(areaId);
        if (area == null) {
            return;
        }
        // 2. æŸ¥è¯¢å½“前库位上已有的库存记录
        List<MesWmMaterialStockDO> stockList = getMaterialStockListByAreaId(areaId);
@@ -388,8 +391,8 @@
                || ObjUtil.notEqual(stock.getBatchId(), batchId)
                || (batchCode != null && ObjUtil.notEqual(stock.getBatchCode(), batchCode))
                || ObjUtil.notEqual(stock.getWarehouseId(), warehouseId)
                || ObjUtil.notEqual(stock.getLocationId(), locationId)
                || ObjUtil.notEqual(stock.getAreaId(), areaId)) {
                || (locationId != null && ObjUtil.notEqual(stock.getLocationId(), locationId))
                || (areaId != null && ObjUtil.notEqual(stock.getAreaId(), areaId))) {
            throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
        }
        // 3. æ ¡éªŒåº“存记录必须是合格库(暂存库存未质检,不可用于出库/占用)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesDetailServiceImpl.java
@@ -6,6 +6,10 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstockbag.MesWmMaterialStockBagMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import org.springframework.transaction.annotation.Transactional;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
@@ -40,9 +44,26 @@
    private MesWmMaterialStockService materialStockService;
    @Resource
    private MesWmWarehouseAreaService warehouseAreaService;
    @Resource
    private MesWmMaterialStockBagMapper stockBagMapper;
    @Resource
    private MesWmProductSalesMapper productSalesMapper;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createProductSalesDetail(MesWmProductSalesDetailSaveReqVO createReqVO) {
        lockPickingOrder(createReqVO.getSalesId());
        if (createReqVO.getRemark() != null && createReqVO.getRemark().matches("移动端无袋码拣货 #[a-f0-9-]{36}")) {
            var existing = productSalesDetailMapper.selectListBySalesId(createReqVO.getSalesId()).stream()
                    .filter(d -> createReqVO.getRemark().equals(d.getRemark())).findFirst().orElse(null);
            if (existing != null) {
                if (ObjUtil.notEqual(existing.getLineId(), createReqVO.getLineId())
                        || ObjUtil.notEqual(existing.getMaterialStockId(), createReqVO.getMaterialStockId())
                        || existing.getQuantity().compareTo(createReqVO.getQuantity()) != 0)
                    throw new IllegalArgumentException("拣货请求标识已用于其他明细");
                return existing.getId();
            }
        }
        // æ ¡éªŒæ•°æ®
        validateProductSalesDetailSaveData(createReqVO);
@@ -53,9 +74,13 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateProductSalesDetail(MesWmProductSalesDetailSaveReqVO updateReqVO) {
        // æ ¡éªŒå­˜åœ¨
        MesWmProductSalesDetailDO existing = validateProductSalesDetailExists(updateReqVO.getId());
        lockPickingOrder(existing.getSalesId());
        if (existing.getShipmentId() != null) throw new IllegalArgumentException("已分配发货记录的明细不能修改");
        if (ObjUtil.notEqual(existing.getSalesId(), updateReqVO.getSalesId())) throw exception(WM_PRODUCT_SALES_DETAIL_LINE_NOT_MATCH);
        if (existing.getBagCodeId() != null) throw new IllegalArgumentException("扫码袋码明细不能手工修改数量或库存,请删除后重新扫码选择");
        // æ ¡éªŒæ•°æ®
        validateProductSalesDetailSaveData(updateReqVO);
@@ -66,9 +91,12 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteProductSalesDetail(Long id) {
        // æ ¡éªŒå­˜åœ¨
        validateProductSalesDetailExists(id);
        MesWmProductSalesDetailDO detail = validateProductSalesDetailExists(id);
        lockPickingOrder(detail.getSalesId());
        if (detail.getShipmentId() != null) throw new IllegalArgumentException("已分配发货记录的明细不能删除");
        // åˆ é™¤
        productSalesDetailMapper.deleteById(id);
    }
@@ -106,7 +134,19 @@
        return detail;
    }
    private void lockPickingOrder(Long salesId) {
        var sales = productSalesMapper.selectByIdForUpdate(salesId);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (!MesWmProductSalesStatusEnum.APPROVING.getStatus().equals(sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
        }
    }
    private void validateProductSalesDetailSaveData(MesWmProductSalesDetailSaveReqVO reqVO) {
        if (reqVO.getQuantity() == null || reqVO.getQuantity().signum() <= 0) throw new IllegalArgumentException("拣货数量必须大于零");
        if (reqVO.getMaterialStockId() != null && stockBagMapper.selectOneActiveByMaterialStockId(reqVO.getMaterialStockId()) != null) {
            throw new IllegalArgumentException("该库存存在袋码明细,请选择实际拣出的袋码");
        }
        // æ ¡éªŒçˆ¶æ•°æ®ï¼ˆè¡Œï¼‰å­˜åœ¨
        MesWmProductSalesLineDO line = productSalesLineService.getProductSalesLine(reqVO.getLineId());
        if (line == null) {
@@ -115,11 +155,19 @@
        if (ObjUtil.notEqual(line.getSalesId(), reqVO.getSalesId())) {
            throw exception(WM_PRODUCT_SALES_DETAIL_LINE_NOT_MATCH);
        }
        var picked = productSalesDetailMapper.selectListByLineId(line.getId()).stream()
                .filter(d -> !java.util.Objects.equals(d.getId(), reqVO.getId()))
                .map(MesWmProductSalesDetailDO::getQuantity).reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
        if (line.getQuantity() == null || picked.add(reqVO.getQuantity()).compareTo(line.getQuantity()) > 0)
            throw new IllegalArgumentException("累计拣货数量不能超过出库单行计划数量");
        // æ ¡éªŒç‰©æ–™å­˜åœ¨
        mdmItemService.validateItemExistsAndEnable(reqVO.getItemId());
        if (ObjUtil.notEqual(line.getItemId(), reqVO.getItemId())) {
            throw exception(WM_PRODUCT_SALES_DETAIL_ITEM_MISMATCH);
        }
        if (line.getBatchCode() != null && !line.getBatchCode().isBlank()
                && !java.util.Objects.equals(line.getBatchCode(), reqVO.getBatchCode()))
            throw new IllegalArgumentException("所选库存批次与出库单行批次不一致");
        // æ ¡éªŒåº“位存在
        warehouseAreaService.validateWarehouseAreaExists(
                reqVO.getWarehouseId(), reqVO.getLocationId(), reqVO.getAreaId());
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesService.java
@@ -78,6 +78,8 @@
     */
    void stockProductSales(Long id);
    List<cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO> getShipments(Long id);
    MesWmProductSalesScanRespVO scanProductSales(@Valid MesWmProductSalesScanReqVO reqVO);
    MesWmProductSalesScanRespVO previewScan(String code);
@@ -104,6 +106,10 @@
     */
    void cancelProductSales(Long id);
    void closeRemaining(Long id, String reason);
    void cancelShipment(Long id);
    /**
     * OQC æ£€éªŒå®ŒæˆåŽï¼Œè‡ªåŠ¨æµè½¬ï¼ˆå¾…æ£€æµ‹ â†’ å¾…拣货)
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -71,6 +71,7 @@
@Validated
@Slf4j
public class MesWmProductSalesServiceImpl implements MesWmProductSalesService {
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesShipmentMapper shipmentMapper;
    @Resource
    private MesWmProductSalesMapper productSalesMapper;
@@ -181,20 +182,26 @@
    @Override
    public boolean checkProductSalesQuantity(Long id) {
        // æ ¡éªŒæ¯è¡Œæ˜Žç»†æ•°é‡ä¹‹å’Œæ˜¯å¦ç­‰äºŽè¡Œå‡ºåº“数量
        // æœ¬æ¬¡è‡³å°‘拣一条,累计拣货不超过计划;允许跨行、分次发货。
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(id);
        boolean hasPending = false;
        for (MesWmProductSalesLineDO line : lines) {
            List<MesWmProductSalesDetailDO> details = productSalesDetailService.getProductSalesDetailListByLineId(line.getId());
            if (CollUtil.isEmpty(details)) {
                return false;
            }
            if (details.stream().anyMatch(d -> d.getShipmentId() == null)) hasPending = true;
            if (details.stream().anyMatch(d -> d.getQuantity() == null || d.getQuantity().signum() <= 0)) return false;
            BigDecimal totalDetailQty = CollectionUtils.getSumValue(details,
                    MesWmProductSalesDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO);
            if (line.getQuantity() != null && totalDetailQty.compareTo(line.getQuantity()) != 0) {
            if (line.getQuantity() == null || totalDetailQty.compareTo(line.getQuantity()) > 0) {
                return false;
            }
        }
        return true;
        return hasPending;
    }
    @Override
    public List<cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO> getShipments(Long id) {
        validateProductSalesExists(id);
        return shipmentMapper.selectBySalesId(id);
    }
    private record PickingScope(String type, Long id, MesProBatchDO batch, List<MesProBagCodeDO> bags) { }
@@ -272,7 +279,8 @@
        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);
                    .setPalletId(b.getPalletId()).setMaterialStockId(stockBags.get(b.getId()) == null ? null : stockBags.get(b.getId()).getMaterialStockId())
                    .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)
@@ -282,6 +290,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesWmProductSalesScanRespVO scanProductSales(MesWmProductSalesScanReqVO reqVO) {
        if (Boolean.TRUE.equals(reqVO.getManual()) && (reqVO.getLineId() == null || reqVO.getMaterialStockId() == null))
            throw new IllegalArgumentException("手工选择袋码必须指定出库单行和库存记录");
        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);
@@ -302,6 +312,10 @@
        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 > 0 && own.stream().anyMatch(d -> selectedIds.contains(d.getBagCodeId())
                && (reqVO.getLineId() != null && !Objects.equals(reqVO.getLineId(), d.getLineId())
                    || reqVO.getMaterialStockId() != null && !Objects.equals(reqVO.getMaterialStockId(), d.getMaterialStockId()))))
            throw new IllegalArgumentException("所选袋码已加入本单其他行或库存记录");
        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("所选袋码已在本单拣货明细中");
@@ -313,9 +327,17 @@
        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);
            if (reqVO.getMaterialStockId() != null && !Objects.equals(reqVO.getMaterialStockId(), stockBags.get(bag.getId()).getMaterialStockId()))
                throw new IllegalArgumentException("所选袋码不属于指定库存记录");
        }
        MesWmProductSalesLineDO line = productSalesLineService.getProductSalesLineListBySalesId(sales.getId()).stream()
                .filter(l -> Objects.equals(l.getItemId(), scope.batch().getItemId())).findFirst().orElse(null);
                .filter(l -> Objects.equals(l.getItemId(), scope.batch().getItemId())
                        && (reqVO.getLineId() == null || Objects.equals(l.getId(), reqVO.getLineId()))
                        && (l.getBatchCode() == null || l.getBatchCode().isBlank() || Objects.equals(l.getBatchCode(), wmBatch.getCode())))
                .filter(l -> l.getQuantity() != null && productSalesDetailService.getProductSalesDetailListByLineId(l.getId()).stream()
                        .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add)
                        .add(quantity).compareTo(l.getQuantity()) <= 0)
                .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);
@@ -334,7 +356,11 @@
                    .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());
                    .setRemark((Boolean.TRUE.equals(reqVO.getManual()) ? "手工选择;" : "") + "扫码类型 " + scope.type() + ";" + switch (scope.type()) {
                        case "BATCH" -> "来源批次码 ";
                        case "PALLET" -> "来源托盘码 ";
                        default -> "来源袋码 ";
                    } + reqVO.getScanContent() + ";袋码 " + bag.getCode());
            productSalesDetailMapper.insert(detail);
            if (firstDetail == null) firstDetail = detail.getId();
        }
@@ -347,19 +373,21 @@
    @Transactional(rollbackFor = Exception.class)
    public void stockProductSales(Long id) {
        // æ ¡éªŒå­˜åœ¨
        MesWmProductSalesDO sales = validateProductSalesExists(id);
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.APPROVING.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
        }
        if (!checkProductSalesQuantity(id)) throw new IllegalArgumentException("请拣选本次发货明细,且累计数量不能超过计划数量");
        var shipment = new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO();
        shipment.setSalesId(id); shipment.setStatus(10); shipmentMapper.insert(shipment);
        // æ ¡éªŒæ¯è¡Œè‡³å°‘有拣货明细,并校验库存充足
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(id);
        for (MesWmProductSalesLineDO line : lines) {
            List<MesWmProductSalesDetailDO> details = productSalesDetailService.getProductSalesDetailListByLineId(line.getId());
            if (CollUtil.isEmpty(details)) {
                throw exception(WM_PRODUCT_SALES_DETAILS_EMPTY);
            }
            // æ ¡éªŒåº“存充足并创建占用
            for (MesWmProductSalesDetailDO detail : details) {
                if (detail.getShipmentId() != null) continue;
                materialStockService.validateSelectedStock(
                        detail.getMaterialStockId(),
                        detail.getItemId(),
@@ -380,6 +408,7 @@
                        detail.getItemId(),
                        detail.getQuantity()
                );
                productSalesDetailMapper.updateById(new MesWmProductSalesDetailDO().setId(detail.getId()).setShipmentId(shipment.getId()));
            }
        }
@@ -392,10 +421,15 @@
    @Transactional(rollbackFor = Exception.class)
    public void shippingProductSales(MesWmProductSalesShippingReqVO reqVO) {
        // æ ¡éªŒå­˜åœ¨
        MesWmProductSalesDO sales = validateProductSalesExists(reqVO.getId());
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(reqVO.getId());
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.SHIPPING.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_SHIPPING);
        }
        var shipment = ensureActiveShipment(sales, 10);
        shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(20).setCarrier(reqVO.getCarrier()).setShippingNumber(reqVO.getShippingNumber()));
        // å¡«å†™è¿å•(待填写运单 â†’ å¾…执行出库)
        productSalesMapper.updateById(new MesWmProductSalesDO()
@@ -409,16 +443,31 @@
    @Transactional(rollbackFor = Exception.class)
    public void finishProductSales(Long id) {
        // 1. æ ¡éªŒå­˜åœ¨
        MesWmProductSalesDO sales = validateProductSalesExists(id);
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.APPROVED.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_FINISH);
        }
        var shipment = ensureActiveShipment(sales, 20);
        List<MesWmProductSalesDetailDO> outDetails = productSalesDetailService.getProductSalesDetailListBySalesId(id).stream()
                .filter(d -> Objects.equals(d.getShipmentId(), shipment.getId())).toList();
        if (outDetails.isEmpty()) throw new IllegalArgumentException("本次发货没有出库明细");
        // 2. ç¡®è®¤å ç”¨è½¬å‡ºåº“(释放占用量)
        stockReserveService.confirmReserveToOutbound(MesWmStockReserveBizTypeEnum.PRODUCT_SALES.getType(), id);
        // 3. éåŽ†æ‰€æœ‰æ˜Žç»†ï¼Œåˆ›å»ºåº“å­˜äº‹åŠ¡ï¼ˆæ‰£å‡åº“å­˜ + è®°å½•流水)
        createTransactionList(sales);
        createTransactionList(sales, outDetails);
        shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(30).setOutboundTime(java.time.LocalDateTime.now())
                .setOutboundUserId(SecurityFrameworkUtils.getLoginUserId()));
        boolean complete = productSalesLineService.getProductSalesLineListBySalesId(id).stream().allMatch(line -> {
            BigDecimal total = productSalesDetailService.getProductSalesDetailListByLineId(line.getId()).stream()
                    .filter(d -> d.getShipmentId() != null).map(MesWmProductSalesDetailDO::getQuantity)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            return line.getQuantity() != null && total.compareTo(line.getQuantity()) == 0;
        });
        // 4. å›ºåŒ–经销商与实际出库操作信息
        CrmCustomerRespDTO customer = customerApi.getCustomer(sales.getClientId());
@@ -427,15 +476,14 @@
                .setClientName(customer != null ? customer.getName() : null)
                .setOutboundTime(java.time.LocalDateTime.now())
                .setOutboundUserId(SecurityFrameworkUtils.getLoginUserId())
                .setStatus(MesWmProductSalesStatusEnum.FINISHED.getStatus()));
                .setStatus(complete ? MesWmProductSalesStatusEnum.FINISHED.getStatus() : MesWmProductSalesStatusEnum.APPROVING.getStatus()));
        // 5. æ›´æ–°å…³è”的发货通知单出库数量和状态
        if (sales.getNoticeId() != null) {
            updateSalesNoticeOutCount(sales);
            updateSalesNoticeOutCount(sales, outDetails);
        }
        // 6. å›žå†™è¢‹ç /托盘出库去向(供窜货稽查定位经销商地区),并生成出库完整性摘要
        List<MesWmProductSalesDetailDO> outDetails = productSalesDetailService.getProductSalesDetailListBySalesId(id);
        for (MesWmProductSalesDetailDO detail : outDetails) {
            if (detail.getBagCodeId() != null) {
                bagCodeService.markOutbound(detail.getBagCodeId(), id);
@@ -445,13 +493,13 @@
                palletService.markOutbound(detail.getPalletCodeId(), id);
            }
        }
        integrityService.recordSalesIntegrity(id);
        if (complete) integrityService.recordSalesIntegrity(id);
    }
    /**
     * æ›´æ–°å‘货通知单的出库数量和状态
     */
    private void updateSalesNoticeOutCount(MesWmProductSalesDO sales) {
    private void updateSalesNoticeOutCount(MesWmProductSalesDO sales, List<MesWmProductSalesDetailDO> outDetails) {
        // èŽ·å–å‡ºåº“å•è¡Œ
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(sales.getId());
        if (CollUtil.isEmpty(lines)) {
@@ -459,44 +507,109 @@
        }
        // éåŽ†è¡Œï¼ŒæŒ‰ noticeLineId æ›´æ–°å‡ºåº“数量
        for (MesWmProductSalesLineDO line : lines) {
            if (line.getNoticeLineId() != null && line.getQuantity() != null) {
                salesNoticeService.updateSalesNoticeLineOutCount(line.getNoticeLineId(), line.getQuantity());
            BigDecimal quantity = outDetails.stream().filter(d -> Objects.equals(d.getLineId(), line.getId()))
                    .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add);
            if (line.getNoticeLineId() != null && quantity.signum() > 0) {
                salesNoticeService.updateSalesNoticeLineOutCount(line.getNoticeLineId(), quantity);
            }
        }
    }
    private void createTransactionList(MesWmProductSalesDO sales) {
        List<MesWmProductSalesDetailDO> details = productSalesDetailService.getProductSalesDetailListBySalesId(sales.getId());
    private void createTransactionList(MesWmProductSalesDO sales, List<MesWmProductSalesDetailDO> details) {
        Set<Long> bagIds = details.stream().map(MesWmProductSalesDetailDO::getBagCodeId).filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, MesProBagCodeDO> bags = bagIds.isEmpty() ? Map.of() : pickingBagMapper.selectBatchIds(bagIds).stream()
                .collect(Collectors.toMap(MesProBagCodeDO::getId, b -> b));
        wmTransactionService.createTransactionList(convertList(details, detail -> new MesWmTransactionSaveReqDTO()
                .setType(MesWmTransactionTypeEnum.OUT.getType()).setItemId(detail.getItemId())
                .setQuantity(detail.getQuantity().negate()) // å‡ºåº“数量为负数
                .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
                .setBagCodeId(detail.getBagCodeId()) // æ–°æ‰«ç æ˜Žç»†é€è¢‹å…³è”,执行出库逐袋更新库存状态
                .setShipmentId(detail.getShipmentId())
                .setBagCode(detail.getBagCodeId() == null || !bags.containsKey(detail.getBagCodeId()) ? null : bags.get(detail.getBagCodeId()).getCode())
                .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
                .setBizType(MesBizTypeConstants.WM_PRODUCT_SALES).setBizId(sales.getId())
                .setBizCode(sales.getCode()).setBizLineId(detail.getLineId())
                .setMaterialStockId(detail.getMaterialStockId()))); // æŒ‡å®šåº“存记录ID,避免重新匹配
    }
    /** å…¼å®¹å‡çº§å‰å·²è¿›å…¥å¾…运单或待出库状态的旧单据。调用方须持有出库单行锁。 */
    private cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO ensureActiveShipment(
            MesWmProductSalesDO sales, int expectedStatus) {
        var shipment = shipmentMapper.selectActive(sales.getId());
        if (shipment != null) {
            if (!Integer.valueOf(expectedStatus).equals(shipment.getStatus())) throw new IllegalArgumentException("发货记录状态异常,请刷新后重试");
            return shipment;
        }
        shipment = new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO();
        shipment.setSalesId(sales.getId()); shipment.setStatus(expectedStatus);
        shipment.setCarrier(sales.getCarrier()); shipment.setShippingNumber(sales.getShippingNumber());
        shipmentMapper.insert(shipment);
        for (MesWmProductSalesDetailDO detail : productSalesDetailService.getProductSalesDetailListBySalesId(sales.getId())) {
            if (detail.getShipmentId() == null)
                productSalesDetailMapper.updateById(new MesWmProductSalesDetailDO().setId(detail.getId()).setShipmentId(shipment.getId()));
        }
        return shipment;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelProductSales(Long id) {
        // æ ¡éªŒå­˜åœ¨
        MesWmProductSalesDO sales = validateProductSalesExists(id);
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        // å·²å®Œæˆå’Œå·²å–消不允许取消
        if (ObjectUtils.equalsAny(sales.getStatus(),
                MesWmProductSalesStatusEnum.FINISHED.getStatus(),
                MesWmProductSalesStatusEnum.CANCELED.getStatus())) {
                MesWmProductSalesStatusEnum.CANCELED.getStatus(), MesWmProductSalesStatusEnum.CLOSED.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_CANCEL);
        }
        if (shipmentMapper.selectBySalesId(id).stream().anyMatch(s -> Integer.valueOf(30).equals(s.getStatus())))
            throw new IllegalArgumentException("该单已有出库记录,请关闭剩余数量,不能取消已发货单据");
        // é‡Šæ”¾åº“存占用
        stockReserveService.releaseReserve(MesWmStockReserveBizTypeEnum.PRODUCT_SALES.getType(), id);
        var shipment = shipmentMapper.selectActive(id);
        if (shipment != null) shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(40));
        // å–消
        productSalesMapper.updateById(new MesWmProductSalesDO()
                .setId(id).setStatus(MesWmProductSalesStatusEnum.CANCELED.getStatus()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void closeRemaining(Long id, String reason) {
        var sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (!MesWmProductSalesStatusEnum.APPROVING.getStatus().equals(sales.getStatus()))
            throw new IllegalArgumentException("仅待拣货状态可关闭剩余数量");
        if (reason == null || reason.isBlank() || reason.trim().length() > 500)
            throw new IllegalArgumentException("请填写不超过500字的关闭原因");
        if (shipmentMapper.selectBySalesId(id).stream().noneMatch(s -> Integer.valueOf(30).equals(s.getStatus())))
            throw new IllegalArgumentException("尚未发生出库,请使用取消操作");
        if (productSalesDetailService.getProductSalesDetailListBySalesId(id).stream().anyMatch(d -> d.getShipmentId() == null))
            throw new IllegalArgumentException("请先删除本次未发货的拣货明细");
        productSalesMapper.updateById(new MesWmProductSalesDO().setId(id)
                .setStatus(MesWmProductSalesStatusEnum.CLOSED.getStatus()).setCloseReason(reason.trim()));
        integrityService.recordSalesIntegrity(id);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelShipment(Long id) {
        var sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (!ObjectUtils.equalsAny(sales.getStatus(), MesWmProductSalesStatusEnum.SHIPPING.getStatus(), MesWmProductSalesStatusEnum.APPROVED.getStatus()))
            throw new IllegalArgumentException("仅待运单或待出库的发货记录可取消");
        int expected = MesWmProductSalesStatusEnum.SHIPPING.getStatus().equals(sales.getStatus()) ? 10 : 20;
        var shipment = ensureActiveShipment(sales, expected);
        stockReserveService.releaseReserve(MesWmStockReserveBizTypeEnum.PRODUCT_SALES.getType(), id);
        productSalesDetailMapper.unassignShipment(shipment.getId());
        shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(40));
        productSalesMapper.updateById(new MesWmProductSalesDO().setId(id).setStatus(MesWmProductSalesStatusEnum.APPROVING.getStatus()));
    }
    @Override
    public void confirmProductSales(Long id) {
        // æ ¡éªŒå­˜åœ¨ + å¾…检测状态
        MesWmProductSalesDO sales = validateProductSalesExists(id);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/transaction/MesWmTransactionServiceImpl.java
@@ -94,10 +94,11 @@
                throw exception(WM_MATERIAL_STOCK_NOT_EXISTS);
            }
            // æ ¡éªŒç‰©æ–™ã€æ‰¹æ¬¡ã€ä»“库等维度一致(防止传入错误的 materialStockId)
            // åº“区/库位为可选维度,未指定时不参与比对
            if (ObjUtil.notEqual(materialStock.getItemId(), reqDTO.getItemId())
                    || ObjUtil.notEqual(materialStock.getWarehouseId(), reqDTO.getWarehouseId())
                    || ObjUtil.notEqual(materialStock.getLocationId(), reqDTO.getLocationId())
                    || ObjUtil.notEqual(materialStock.getAreaId(), reqDTO.getAreaId())) {
                    || (reqDTO.getLocationId() != null && ObjUtil.notEqual(materialStock.getLocationId(), reqDTO.getLocationId()))
                    || (reqDTO.getAreaId() != null && ObjUtil.notEqual(materialStock.getAreaId(), reqDTO.getAreaId()))) {
                throw exception(WM_MATERIAL_STOCK_SELECTION_MISMATCH);
            }
        } else if (isInbound) {
@@ -154,6 +155,7 @@
                .type(reqDTO.getType()).quantity(reqDTO.getQuantity()).transactionTime(LocalDateTime.now())
                .itemId(reqDTO.getItemId()).batchId(reqDTO.getBatchId()).batchCode(reqDTO.getBatchCode())
                .bagCodeId(reqDTO.getBagCodeId()).bagCode(reqDTO.getBagCode())
                .shipmentId(reqDTO.getShipmentId())
                .warehouseId(reqDTO.getWarehouseId()).locationId(reqDTO.getLocationId()).areaId(reqDTO.getAreaId())
                .bizType(reqDTO.getBizType()).bizId(reqDTO.getBizId()).bizCode(reqDTO.getBizCode()).bizLineId(reqDTO.getBizLineId())
                .materialStockId(materialStock.getId()).relatedTransactionId(reqDTO.getRelatedTransactionId())
@@ -279,20 +281,21 @@
        if (Boolean.TRUE.equals(warehouse.getFrozen())) {
            throw exception(WM_TRANSACTION_WAREHOUSE_FROZEN, warehouse.getName());
        }
        // 1.2 æ£€æŸ¥åº“区冻结
        // 1.2 æ£€æŸ¥åº“区冻结(未使用库区时跳过)
        MesWmWarehouseLocationDO location = locationService.validateWarehouseLocationExists(reqDTO.getLocationId());
        if (Boolean.TRUE.equals(location.getFrozen())) {
        if (location != null && Boolean.TRUE.equals(location.getFrozen())) {
            throw exception(WM_TRANSACTION_LOCATION_FROZEN, location.getName());
        }
        // 1.3 æ£€æŸ¥åº“位冻结
        // 1.3 æ£€æŸ¥åº“位冻结(未使用库位时跳过)
        MesWmWarehouseAreaDO area = areaService.validateWarehouseAreaExists(reqDTO.getAreaId());
        if (Boolean.TRUE.equals(area.getFrozen())) {
        if (area != null && Boolean.TRUE.equals(area.getFrozen())) {
            throw exception(WM_TRANSACTION_AREA_FROZEN, area.getName());
        }
        // 2. æ£€æŸ¥åº“存记录冻结
        if (Boolean.TRUE.equals(materialStock.getFrozen())) {
            throw exception(WM_TRANSACTION_STOCK_FROZEN, warehouse.getName(), location.getName(), area.getName());
            throw exception(WM_TRANSACTION_STOCK_FROZEN, warehouse.getName(),
                    location != null ? location.getName() : "", area != null ? area.getName() : "");
        }
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/transaction/dto/MesWmTransactionSaveReqDTO.java
@@ -73,6 +73,7 @@
    private Long bagCodeId;
    /** è¢‹ç  */
    private String bagCode;
    private Long shipmentId;
    // ===== åº“存位置 =====
@@ -82,14 +83,12 @@
    @NotNull(message = "仓库 ID ä¸èƒ½ä¸ºç©º")
    private Long warehouseId;
    /**
     * åº“区 ID
     * åº“区 ID(可选)
     */
    @NotNull(message = "库区 ID ä¸èƒ½ä¸ºç©º")
    private Long locationId;
    /**
     * åº“位 ID
     * åº“位 ID(可选)
     */
    @NotNull(message = "库位 ID ä¸èƒ½ä¸ºç©º")
    private Long areaId;
    // ===== å°è´¦è¡¥å……字段(入库时用于初始化 MesWmMaterialStockDO) =====
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/warehouse/MesWmWarehouseAreaServiceImpl.java
@@ -116,6 +116,9 @@
    @Override
    public MesWmWarehouseAreaDO validateWarehouseAreaExists(Long id) {
        if (id == null) {
            return null;
        }
        MesWmWarehouseAreaDO area = areaMapper.selectById(id);
        if (area == null) {
            throw exception(WM_WAREHOUSE_AREA_NOT_EXISTS);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/warehouse/MesWmWarehouseLocationServiceImpl.java
@@ -119,6 +119,9 @@
    @Override
    public MesWmWarehouseLocationDO validateWarehouseLocationExists(Long id) {
        if (id == null) {
            return null;
        }
        MesWmWarehouseLocationDO location = locationMapper.selectById(id);
        if (location == null) {
            throw exception(WM_WAREHOUSE_LOCATION_NOT_EXISTS);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/warehouse/MesWmWarehouseServiceImpl.java
@@ -37,8 +37,6 @@
    private MesWmWarehouseMapper warehouseMapper;
    @Resource
    private MesWmWarehouseLocationService locationService;
    @Resource
    @Lazy
    private MesMdWorkstationService workstationService;
    @Resource
@@ -96,10 +94,6 @@
        MesWmWarehouseDO warehouse = validateWarehouseExists(id);
        // æ ¡éªŒè™šæ‹Ÿä»“库不允许删除
        validateNotVirtual(warehouse.getCode());
        // æ ¡éªŒæ˜¯å¦æœ‰åº“区
        if (locationService.getWarehouseLocationCountByWarehouseId(id) > 0) {
            throw exception(WM_WAREHOUSE_HAS_LOCATION);
        }
        // æ ¡éªŒæ˜¯å¦è¢«å·¥ä½œç«™å¼•用
        if (workstationService.getWorkstationCountByWarehouseId(id) > 0) {
            throw exception(WM_WAREHOUSE_HAS_WORKSTATION);
yudao-module-mes/src/main/resources/bpm/mes_seed_quality_company_approve.bpmn20.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" targetNamespace="http://www.flowable.org/processdef">
  <process id="mes_seed_quality_company_approve" name="种子公司质检审批" isExecutable="true">
    <startEvent id="start" />
    <sequenceFlow id="start_to_review" sourceRef="start" targetRef="review" />
    <userTask id="review" name="公司质检审批(暂由 admin å¤„理)" flowable:candidateStrategy="30" flowable:candidateParam="1" />
    <sequenceFlow id="review_to_end" sourceRef="review" targetRef="end" />
    <endEvent id="end" />
  </process>
  <bpmndi:BPMNDiagram id="BPMNDiagram_mes_seed_quality_company_approve">
    <bpmndi:BPMNPlane bpmnElement="mes_seed_quality_company_approve" id="BPMNPlane_mes_seed_quality_company_approve">
      <bpmndi:BPMNShape bpmnElement="start" id="BPMNShape_start">
        <omgdc:Bounds height="36.0" width="36.0" x="160.0" y="182.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape bpmnElement="review" id="BPMNShape_review">
        <omgdc:Bounds height="80.0" width="100.0" x="250.0" y="160.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape bpmnElement="end" id="BPMNShape_end">
        <omgdc:Bounds height="36.0" width="36.0" x="400.0" y="182.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNEdge bpmnElement="start_to_review" id="BPMNEdge_start_to_review">
        <omgdi:waypoint x="196.0" y="200.0"></omgdi:waypoint>
        <omgdi:waypoint x="250.0" y="200.0"></omgdi:waypoint>
      </bpmndi:BPMNEdge>
      <bpmndi:BPMNEdge bpmnElement="review_to_end" id="BPMNEdge_review_to_end">
        <omgdi:waypoint x="350.0" y="200.0"></omgdi:waypoint>
        <omgdi:waypoint x="400.0" y="200.0"></omgdi:waypoint>
      </bpmndi:BPMNEdge>
    </bpmndi:BPMNPlane>
  </bpmndi:BPMNDiagram>
</definitions>
yudao-module-mes/src/main/resources/bpm/mes_seed_quality_line_approve.bpmn20.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" targetNamespace="http://www.flowable.org/processdef">
  <process id="mes_seed_quality_line_approve" name="种子质检部门审批" isExecutable="true">
    <startEvent id="start" />
    <sequenceFlow id="start_to_review" sourceRef="start" targetRef="review" />
    <userTask id="review" name="部门领导审批(暂由 admin å¤„理)" flowable:candidateStrategy="30" flowable:candidateParam="1" />
    <sequenceFlow id="review_to_end" sourceRef="review" targetRef="end" />
    <endEvent id="end" />
  </process>
  <bpmndi:BPMNDiagram id="BPMNDiagram_mes_seed_quality_line_approve">
    <bpmndi:BPMNPlane bpmnElement="mes_seed_quality_line_approve" id="BPMNPlane_mes_seed_quality_line_approve">
      <bpmndi:BPMNShape bpmnElement="start" id="BPMNShape_start">
        <omgdc:Bounds height="36.0" width="36.0" x="160.0" y="182.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape bpmnElement="review" id="BPMNShape_review">
        <omgdc:Bounds height="80.0" width="100.0" x="250.0" y="160.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape bpmnElement="end" id="BPMNShape_end">
        <omgdc:Bounds height="36.0" width="36.0" x="400.0" y="182.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNEdge bpmnElement="start_to_review" id="BPMNEdge_start_to_review">
        <omgdi:waypoint x="196.0" y="200.0"></omgdi:waypoint>
        <omgdi:waypoint x="250.0" y="200.0"></omgdi:waypoint>
      </bpmndi:BPMNEdge>
      <bpmndi:BPMNEdge bpmnElement="review_to_end" id="BPMNEdge_review_to_end">
        <omgdi:waypoint x="350.0" y="200.0"></omgdi:waypoint>
        <omgdi:waypoint x="400.0" y="200.0"></omgdi:waypoint>
      </bpmndi:BPMNEdge>
    </bpmndi:BPMNPlane>
  </bpmndi:BPMNDiagram>
</definitions>
yudao-module-mes/src/main/resources/bpm/mes_seed_return_approve.bpmn20.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" targetNamespace="http://www.flowable.org/processdef">
  <process id="mes_seed_return_approve" name="经销商退货种子质检审批" isExecutable="true">
    <startEvent id="start" />
    <sequenceFlow id="start_to_review" sourceRef="start" targetRef="review" />
    <userTask id="review" name="销售部领导审批(暂由 admin å¤„理)" flowable:candidateStrategy="30" flowable:candidateParam="1" />
    <sequenceFlow id="review_to_end" sourceRef="review" targetRef="end" />
    <endEvent id="end" />
  </process>
  <bpmndi:BPMNDiagram id="BPMNDiagram_mes_seed_return_approve">
    <bpmndi:BPMNPlane bpmnElement="mes_seed_return_approve" id="BPMNPlane_mes_seed_return_approve">
      <bpmndi:BPMNShape bpmnElement="start" id="BPMNShape_start">
        <omgdc:Bounds height="36.0" width="36.0" x="160.0" y="182.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape bpmnElement="review" id="BPMNShape_review">
        <omgdc:Bounds height="80.0" width="100.0" x="250.0" y="160.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNShape bpmnElement="end" id="BPMNShape_end">
        <omgdc:Bounds height="36.0" width="36.0" x="400.0" y="182.0"></omgdc:Bounds>
      </bpmndi:BPMNShape>
      <bpmndi:BPMNEdge bpmnElement="start_to_review" id="BPMNEdge_start_to_review">
        <omgdi:waypoint x="196.0" y="200.0"></omgdi:waypoint>
        <omgdi:waypoint x="250.0" y="200.0"></omgdi:waypoint>
      </bpmndi:BPMNEdge>
      <bpmndi:BPMNEdge bpmnElement="review_to_end" id="BPMNEdge_review_to_end">
        <omgdi:waypoint x="350.0" y="200.0"></omgdi:waypoint>
        <omgdi:waypoint x="400.0" y="200.0"></omgdi:waypoint>
      </bpmndi:BPMNEdge>
    </bpmndi:BPMNPlane>
  </bpmndi:BPMNDiagram>
</definitions>
yudao-module-mes/src/main/resources/mapper/wm/batch/MesWmBatchMapper.xml
@@ -63,26 +63,15 @@
    -->
    <select id="selectSalesTargetList" parameterType="String"
            resultType="cn.iocoder.yudao.module.mes.controller.admin.wm.batch.vo.MesWmBatchSalesTargetRespVO">
        SELECT s.code AS sales_code, s.sales_date, s.sales_order_code, s.client_id, s.client_name, s.contact_name,
               oqc.code AS oqc_code, oqc.check_result AS check_result,
               sl.batch_code, sl.item_id, sl.quantity, sl.quality_status
        FROM mes_wm_product_sales_line sl
        JOIN mes_wm_product_sales s ON s.id = sl.sales_id AND s.deleted = 0
        LEFT JOIN mes_qc_oqc oqc ON oqc.id = sl.oqc_id AND oqc.deleted = 0
        WHERE sl.batch_code = #{batchCode} AND sl.deleted = 0 AND s.status != 5
          AND NOT EXISTS (SELECT 1 FROM mes_wm_product_sales_detail d
                          WHERE d.line_id = sl.id AND d.batch_code = #{batchCode} AND d.deleted = 0)
        UNION ALL
        SELECT s.code AS sales_code, s.sales_date, s.sales_order_code, s.client_id, s.client_name, s.contact_name,
               oqc.code AS oqc_code, oqc.check_result AS check_result,
               d.batch_code, d.item_id, SUM(d.quantity) AS quantity, MAX(sl.quality_status) AS quality_status
        FROM mes_wm_product_sales_detail d
        JOIN mes_wm_product_sales s ON s.id = d.sales_id AND s.deleted = 0
        JOIN mes_wm_product_sales_line sl ON sl.id = d.line_id AND sl.deleted = 0
        LEFT JOIN mes_qc_oqc oqc ON oqc.id = sl.oqc_id AND oqc.deleted = 0
        WHERE d.batch_code = #{batchCode} AND d.deleted = 0 AND s.status != 5
        GROUP BY s.id, s.code, s.sales_date, s.sales_order_code, s.client_id, s.client_name, s.contact_name,
                 oqc.code, oqc.check_result, d.batch_code, d.item_id
        SELECT s.code AS sales_code, tx.shipment_id, COALESCE(sh.outbound_time, MAX(tx.transaction_time)) AS sales_date,
               s.sales_order_code, s.client_id, s.client_name, s.contact_name,
               tx.batch_code, tx.item_id, -SUM(tx.quantity) AS quantity
        FROM mes_wm_transaction tx
        JOIN mes_wm_product_sales s ON s.id = tx.biz_id AND s.deleted = 0
        LEFT JOIN mes_wm_product_sales_shipment sh ON sh.id = tx.shipment_id AND sh.deleted = 0
        WHERE tx.batch_code = #{batchCode} AND tx.deleted = 0 AND tx.biz_type = 119 AND tx.type = 2
        GROUP BY s.id, s.code, tx.shipment_id, sh.outbound_time, s.sales_order_code,
                 s.client_id, s.client_name, s.contact_name, tx.batch_code, tx.item_id
        ORDER BY sales_date
    </select>
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedIndicatorRangeServiceTest.java
@@ -1,34 +1,37 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedIndicatorRangeSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedIndicatorRangeDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.MesQcSeedIndicatorRangeMapper;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.mockito.Mockito.*;
class MesQcSeedIndicatorRangeServiceTest {
    private final MesQcSeedIndicatorRangeService service = new MesQcSeedIndicatorRangeService();
    private final MesQcSeedIndicatorRangeMapper mapper = mock(MesQcSeedIndicatorRangeMapper.class);
    private final MesMdmItemService itemService = mock(MesMdmItemService.class);
    @Test
    void savesTemplateWithoutRestrictingActualInspection() {
    void createsIndicatorRangeForProduct() {
        ReflectionTestUtils.setField(service, "mapper", mapper);
        ReflectionTestUtils.setField(service, "mdmItemService", itemService);
        MesMdmItemDO item = new MesMdmItemDO();
        item.setId(1L);
        when(itemService.getItem(1L)).thenReturn(item);
        when(mapper.selectByItemIdAndCode(1L, "purity")).thenReturn(null);
        MesQcSeedIndicatorRangeSaveReqVO request = new MesQcSeedIndicatorRangeSaveReqVO();
        request.setRanges(List.of(item("purity"), item("cleanliness"), item("germination_rate"), item("moisture")));
        service.saveRanges(request);
        verify(mapper, times(4)).insert(any(MesQcSeedIndicatorRangeDO.class));
    }
    private MesQcSeedIndicatorRangeSaveReqVO.Item item(String code) {
        MesQcSeedIndicatorRangeSaveReqVO.Item row = new MesQcSeedIndicatorRangeSaveReqVO.Item();
        row.setIndicatorCode(code);
        row.setMinValue(new BigDecimal("90"));
        row.setMaxValue(new BigDecimal("100"));
        return row;
        request.setItemId(1L);
        request.setIndicatorCode("purity");
        request.setIndicatorName("纯度");
        request.setMinValue(new BigDecimal("90"));
        request.setMaxValue(new BigDecimal("100"));
        service.create(request);
        verify(mapper, times(1)).insert(any(MesQcSeedIndicatorRangeDO.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityGroupTest.java
@@ -4,6 +4,7 @@
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.MesQcSeedIndicatorRangeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityIndicatorDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
@@ -17,6 +18,7 @@
import cn.iocoder.yudao.module.mes.dal.mysql.wm.batch.MesWmBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmWarehouseTypeEnum;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.qc.approver.MesQcApproverService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
@@ -40,11 +42,19 @@
    private final MesWmMaterialStockMapper stocks=mock(MesWmMaterialStockMapper.class);
    private final MesWmWarehouseService warehouses=mock(MesWmWarehouseService.class);
    private final MesQcApproverService approvers=mock(MesQcApproverService.class);
    private final MesQcSeedIndicatorRangeService ranges=mock(MesQcSeedIndicatorRangeService.class);
    private final MesMdmItemService items=mock(MesMdmItemService.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);}
    private List<MesQcSeedQualitySubmitReqVO.Indicator> validIndicators() {
        return List.of(indicator("purity"), indicator("cleanliness"), indicator("germination_rate"), indicator("moisture"));
    }
    private MesQcSeedIndicatorRangeDO range(String code) {
        var range = new MesQcSeedIndicatorRangeDO();
        range.setIndicatorCode(code); range.setIndicatorName(code); range.setUnit("%");
        range.setMinValue(java.math.BigDecimal.ZERO); range.setMaxValue(java.math.BigDecimal.TEN);
        return range;
    }
    private MesQcSeedQualitySubmitReqVO.Indicator indicator(String code) {
        var item = new MesQcSeedQualitySubmitReqVO.Indicator(); item.setCode(code);
@@ -65,6 +75,11 @@
        ReflectionTestUtils.setField(service,"proBatchService",batchService);ReflectionTestUtils.setField(service,"mesWmBatchMapper",wmBatches);
        ReflectionTestUtils.setField(service,"materialStockMapper",stocks);ReflectionTestUtils.setField(service,"warehouseService",warehouses);
        ReflectionTestUtils.setField(service,"approverService",approvers);
        ReflectionTestUtils.setField(service,"rangeService",ranges);
        ReflectionTestUtils.setField(service,"mdmItemService",items);
        when(ranges.getEffectiveRanges(any())).thenReturn(List.of(range("purity"),range("cleanliness"),range("germination_rate"),range("moisture")));
        when(ranges.getEffectiveMap(any())).thenReturn(java.util.Map.of());
        when(items.getItem(any())).thenReturn(null);
        var batch=new MesProBatchDO().setId(10L).setCode("BATCH").setBusinessCode("BATCH-BUS").setItemId(99L).setStatus(30);
        when(batches.selectByScanCode(anyString())).thenReturn(batch);when(batches.selectByScanCodeForUpdate(anyString())).thenReturn(batch);
        when(batchService.getBatch(10L)).thenReturn(batch);
@@ -84,24 +99,27 @@
            java.util.Collection<Long> ids = invocation.getArgument(0);
            return List.of(first, second).stream().filter(bag -> ids.contains(bag.getId())).toList();
        });
        req=new MesQcSeedQualityGroupSubmitReqVO();req.setCode("BATCH-BUS");req.setScanType("BATCH");req.setBagIds(List.of(1L,2L));
        req=new MesQcSeedQualityGroupSubmitReqVO();req.setCode("BATCH-BUS");req.setQcLine(1);req.setScanType("BATCH");req.setBagIds(List.of(1L,2L));
        req.setQualifiedWarehouseId(8L);req.setIndicators(validIndicators());
    }
    @Test void batchPreviewCountsEffectiveInspectedAndPendingBags() {
        when(qualities.selectListByCodes(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").build()));
        when(qualities.selectListByCodes(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").qcLine(1).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(qualities,never()).insertBatch(anyCollection(),anyInt());
    }
    @Test void palletProcessesOnlyConfirmedUninspectedBags() {
        req.setScanType("PALLET");req.setCode("PALLET-BUS");req.setBagIds(List.of(2L));
        when(qualities.selectListByCodes(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").build()));
        when(qualities.selectListByCodes(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").qcLine(1).build()));
        when(qualities.selectListByCodesForUpdate(List.of("B2"))).thenReturn(List.of());
        assertEquals(1,service.submitGroup(req).get("processedCount"));
        verify(qualities).insertBatch(argThat(rows -> rows.size() == 1 && rows.iterator().next().getCode().equals("B2")
                && rows.iterator().next().getQualifiedWarehouseId().equals(8L)),eq(100));
        verify(indicators).insertBatch(argThat(rows -> rows.size() == 4),eq(400));
        verify(actions).insertBatch(argThat(rows -> rows.size() == 1),eq(100));
        // ä¸€æ¬¡æäº¤åŒæ—¶ç”ŸæˆåŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•ï¼ŒæŒ‡æ ‡ä¸Žç»“è®ºä¸€è‡´ã€‚
        verify(qualities).insertBatch(argThat(rows -> rows.size() == 2 && rows.stream().allMatch(row -> row.getCode().equals("B2")
                && row.getQualifiedWarehouseId().equals(8L))
                && rows.stream().map(MesQcSeedQualityDO::getQcLine).collect(java.util.stream.Collectors.toSet())
                .equals(java.util.Set.of(1, 2))),eq(100));
        verify(indicators).insertBatch(argThat(rows -> rows.size() == 8),eq(400));
        verify(actions).insertBatch(argThat(rows -> rows.size() == 2),eq(100));
    }
    @Test void changedConfirmedListDoesNotSubmitAnyBag() {
        req.setBagIds(List.of(1L));assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(qualities,never()).insertBatch(anyCollection(),anyInt());
@@ -138,8 +156,8 @@
    }
    @Test void retestScopePreviewSeparatesWorkflowStages() {
        when(qualities.selectListByCodes(any())).thenReturn(List.of(
                MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(50).build()));
                MesQcSeedQualityDO.builder().id(11L).code("B1").qcLine(1).workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").qcLine(1).workflowStatus(50).build()));
        var result = service.resolveRetestScope("PALLET-BUS", "PALLET");
        assertEquals(List.of(11L), result.get("retestIds"));
        assertEquals(List.of(12L), result.get("initialRecheckIds"));
@@ -148,10 +166,10 @@
        var third = bag(3L); var fourth = bag(4L);
        when(bags.selectListByPalletId(20L)).thenReturn(List.of(first, second, third, fourth));
        when(qualities.selectListByCodes(any())).thenReturn(List.of(
                MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(20).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(25).build(),
                MesQcSeedQualityDO.builder().id(13L).code("B3").workflowStatus(30).build(),
                MesQcSeedQualityDO.builder().id(14L).code("B4").workflowStatus(60).build()));
                MesQcSeedQualityDO.builder().id(11L).code("B1").qcLine(1).workflowStatus(20).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").qcLine(1).workflowStatus(25).build(),
                MesQcSeedQualityDO.builder().id(13L).code("B3").qcLine(1).workflowStatus(30).build(),
                MesQcSeedQualityDO.builder().id(14L).code("B4").qcLine(1).workflowStatus(60).build()));
        var result = service.resolveRetestScope("PALLET-BUS", "PALLET");
        assertEquals(List.of(11L), result.get("pendingProcessIds"));
        assertEquals(List.of(12L), result.get("pendingInitialFailIds"));
@@ -162,22 +180,22 @@
    }
    @Test void retestScopeRejectsStaleConfirmedList() {
        when(qualities.selectListByCodes(any())).thenReturn(List.of(
                MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(10).build()));
                MesQcSeedQualityDO.builder().id(11L).code("B1").qcLine(1).workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").qcLine(1).workflowStatus(10).build()));
        var request = new MesQcSeedQualityScopeRetestReqVO();
        request.setCode("BATCH-BUS"); request.setScanType("BATCH"); request.setWorkflowStatus(10);
        request.setCode("BATCH-BUS"); request.setScanType("BATCH"); request.setQcLine(1); request.setWorkflowStatus(10);
        request.setQualityIds(List.of(11L)); request.setIndicators(validIndicators());
        assertThrows(IllegalArgumentException.class, () -> service.retestScope(request));
        verify(indicators, never()).updateBatch(anyCollection(), anyInt());
    }
    @Test void retestScopeProcessesAllConfirmedBags() {
        var pending = List.of(MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(10).build());
        var pending = List.of(MesQcSeedQualityDO.builder().id(11L).code("B1").qcLine(1).workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").qcLine(1).workflowStatus(10).build());
        when(qualities.selectListByCodes(any())).thenReturn(pending);
        when(qualities.selectListByIdsForUpdate(List.of(11L, 12L))).thenReturn(pending);
        when(indicators.selectListByQualityIds(any())).thenReturn(storedIndicators(11L, 12L));
        var request = new MesQcSeedQualityScopeRetestReqVO();
        request.setCode("BATCH-BUS"); request.setScanType("BATCH"); request.setWorkflowStatus(10);
        request.setCode("BATCH-BUS"); request.setScanType("BATCH"); request.setQcLine(1); request.setWorkflowStatus(10);
        request.setQualityIds(List.of(11L, 12L)); request.setIndicators(validIndicators());
        assertEquals(2, service.retestScope(request).get("processedCount"));
        verify(indicators).selectListByQualityIds(List.of(11L, 12L));
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityScanTest.java
@@ -6,10 +6,13 @@
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 cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.qc.seedquality.MesQcSeedIndicatorRangeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -17,11 +20,19 @@
    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 final MesMdmItemService items = mock(MesMdmItemService.class);
    private final MesQcSeedIndicatorRangeService ranges = mock(MesQcSeedIndicatorRangeService.class);
    @BeforeEach void setup() {
        ReflectionTestUtils.setField(service,"bagCodeMapper",bags);
        ReflectionTestUtils.setField(service,"qualityMapper",qualities);
        ReflectionTestUtils.setField(service,"mdmItemService",items);
        ReflectionTestUtils.setField(service,"rangeService",ranges);
        when(ranges.getEffectiveRanges(any())).thenReturn(List.of());
    }
    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());
        when(qualities.selectListByCode("TRACE")).thenReturn(List.of(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));
@@ -33,14 +44,14 @@
    }
    @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());
        var req = new MesQcSeedQualitySubmitReqVO();req.setCode("BUSINESS");req.setQcLine(1);
        when(qualities.selectListByCodeForUpdate("TRACE")).thenReturn(List.of(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));
        verify(qualities).selectListByCodeForUpdate("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()));
        var req = new MesQcSeedQualitySubmitReqVO();req.setCode("BUSINESS");req.setQcLine(1);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));
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityWorkflowTest.java
@@ -11,7 +11,9 @@
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -32,18 +34,28 @@
    }
    @Test
    void initialApprovalOnlyQueuesRetestAndFailedRetestCanReturnToRetest() {
    void failedProcessStartDoesNotPreventOtherStarts() {
        AtomicBoolean secondStarted = new AtomicBoolean();
        TransactionSynchronizationManager.initSynchronization();
        try {
            ReflectionTestUtils.invokeMethod(service, "afterCommit", (Runnable) () -> {
                throw new IllegalStateException("BPM unavailable");
            });
            ReflectionTestUtils.invokeMethod(service, "afterCommit", (Runnable) () -> secondStarted.set(true));
            TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.afterCommit());
            assertTrue(secondStarted.get());
        } finally {
            TransactionSynchronizationManager.clearSynchronization();
        }
    }
    @Test
    void newDualLineRecordsRequireBpmApproval() {
        when(qualityMapper.selectByIdForUpdate(1L)).thenReturn(
                MesQcSeedQualityDO.builder().id(1L).code("B1").result(2).workflowStatus(20).build(),
                MesQcSeedQualityDO.builder().id(1L).code("B1").result(2).workflowStatus(60).build());
        service.processApprove(1L, true, "");
                MesQcSeedQualityDO.builder().id(1L).code("B1").qcLine(1).result(2).workflowStatus(20).build());
        assertThrows(IllegalArgumentException.class, () -> service.processApprove(1L, true, ""));
        verifyNoInteractions(transactionService);
        service.companyApprove(1L, false, "复检不合格,重新取样");
        ArgumentCaptor<MesQcSeedQualityDO> update = ArgumentCaptor.forClass(MesQcSeedQualityDO.class);
        verify(qualityMapper, times(2)).updateById(update.capture());
        assertEquals(10, update.getAllValues().get(0).getWorkflowStatus());
        assertEquals(10, update.getAllValues().get(1).getWorkflowStatus());
        verifyNoInteractions(transactionService);
        verify(qualityMapper, never()).updateById(any(MesQcSeedQualityDO.class));
    }
    @Test
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/appearance/SystemAppearanceController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.system.controller.admin.appearance;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.system.controller.admin.appearance.vo.SystemAppearanceRespVO;
import cn.iocoder.yudao.module.system.controller.admin.appearance.vo.SystemAppearanceSaveReqVO;
import cn.iocoder.yudao.module.system.service.appearance.SystemAppearanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ç®¡ç†åŽå° - ç³»ç»Ÿå¤–观(登录背景图、企业 Logo)
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - ç³»ç»Ÿå¤–è§‚")
@RestController
@RequestMapping("/system/appearance")
@Validated
public class SystemAppearanceController {
    @Resource
    private SystemAppearanceService appearanceService;
    @GetMapping("/get")
    @Operation(summary = "获得系统外观")
    @PermitAll
    public CommonResult<SystemAppearanceRespVO> getAppearance() {
        return success(appearanceService.getAppearance());
    }
    @PostMapping("/save")
    @Operation(summary = "保存系统外观")
    @PreAuthorize("@ss.hasPermission('system:appearance:update')")
    public CommonResult<Boolean> saveAppearance(@Valid @RequestBody SystemAppearanceSaveReqVO reqVO) {
        appearanceService.saveAppearance(reqVO);
        return success(true);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/appearance/vo/SystemAppearanceRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.system.controller.admin.appearance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - ç³»ç»Ÿå¤–è§‚ Response VO")
@Data
public class SystemAppearanceRespVO {
    @Schema(description = "企业 Logo æ–‡ä»¶ç¼–号")
    private Long logoBlobId;
    @Schema(description = "企业 Logo è®¿é—®åœ°å€")
    private String logoUrl;
    @Schema(description = "登录背景图文件编号")
    private Long loginBackgroundBlobId;
    @Schema(description = "登录背景图访问地址")
    private String loginBackgroundUrl;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/appearance/vo/SystemAppearanceSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.system.controller.admin.appearance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - ç³»ç»Ÿå¤–观保存 Request VO")
@Data
public class SystemAppearanceSaveReqVO {
    @Schema(description = "企业 Logo æ–‡ä»¶ç¼–号,传空表示清除")
    private Long logoBlobId;
    @Schema(description = "登录背景图文件编号,传空表示清除")
    private Long loginBackgroundBlobId;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageApplicationTypeEnum.java
@@ -13,7 +13,9 @@
    FILE("file", "通用文件"),
    IMAGE("image", "图片"),
    AVATAR("avatar", "头像");
    AVATAR("avatar", "头像"),
    LOGO("logo", "企业Logo"),
    LOGIN_BACKGROUND("login_background", "登录背景图");
    private final String type;
    private final String description;
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -32,6 +32,9 @@
    // ===== å¤´åƒ =====
    AVATAR("avatar", "头像"),
    // ===== ç³»ç»Ÿå¤–è§‚ =====
    SYSTEM_APPEARANCE("system_appearance", "系统外观"),
    // ===== ERP é‡‡è´­ =====
    ERP_PURCHASE_REQUEST("erp_purchase_request", "ERP é‡‡è´­ç”³è¯·"),
    ERP_PURCHASE_ORDER("erp_purchase_order", "ERP é‡‡è´­è®¢å•"),
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/appearance/SystemAppearanceService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.system.service.appearance;
import cn.iocoder.yudao.module.system.controller.admin.appearance.vo.SystemAppearanceRespVO;
import cn.iocoder.yudao.module.system.controller.admin.appearance.vo.SystemAppearanceSaveReqVO;
/**
 * ç³»ç»Ÿå¤–è§‚ Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface SystemAppearanceService {
    /**
     * èŽ·å¾—å½“å‰ç³»ç»Ÿå¤–è§‚ï¼ˆç™»å½•é¡µèƒŒæ™¯å›¾ã€ä¼ä¸š Logo)
     *
     * @return ç³»ç»Ÿå¤–è§‚
     */
    SystemAppearanceRespVO getAppearance();
    /**
     * ä¿å­˜ç³»ç»Ÿå¤–è§‚
     *
     * @param reqVO ä¿å­˜ä¿¡æ¯
     */
    void saveAppearance(SystemAppearanceSaveReqVO reqVO);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/appearance/SystemAppearanceServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.system.service.appearance;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.system.controller.admin.appearance.vo.SystemAppearanceRespVO;
import cn.iocoder.yudao.module.system.controller.admin.appearance.vo.SystemAppearanceSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageAttachmentService;
import cn.iocoder.yudao.module.system.util.storage.StorageFileUtil;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
/**
 * ç³»ç»Ÿå¤–è§‚ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
public class SystemAppearanceServiceImpl implements SystemAppearanceService {
    /**
     * ç³»ç»Ÿå¤–观固定记录编号:外观是全局唯一配置,用一条附件记录承载
     */
    private static final Long RECORD_ID = 1L;
    @Resource
    private SystemStorageAttachmentService storageAttachmentService;
    @Resource
    private StorageFileUtil storageFileUtil;
    @Override
    public SystemAppearanceRespVO getAppearance() {
        SystemAppearanceRespVO respVO = new SystemAppearanceRespVO();
        SystemStorageBlobRespVO logo = getSingleBlob(StorageApplicationTypeEnum.LOGO);
        if (logo != null) {
            respVO.setLogoBlobId(logo.getId());
            respVO.setLogoUrl(buildPermanentUrl(logo));
        }
        SystemStorageBlobRespVO background = getSingleBlob(StorageApplicationTypeEnum.LOGIN_BACKGROUND);
        if (background != null) {
            respVO.setLoginBackgroundBlobId(background.getId());
            respVO.setLoginBackgroundUrl(buildPermanentUrl(background));
        }
        return respVO;
    }
    @Override
    public void saveAppearance(SystemAppearanceSaveReqVO reqVO) {
        saveBlob(StorageApplicationTypeEnum.LOGO, reqVO.getLogoBlobId());
        saveBlob(StorageApplicationTypeEnum.LOGIN_BACKGROUND, reqVO.getLoginBackgroundBlobId());
    }
    private SystemStorageBlobRespVO getSingleBlob(StorageApplicationTypeEnum application) {
        SystemStorageAttachmentListReqVO reqVO = new SystemStorageAttachmentListReqVO();
        reqVO.setApplication(application.getType());
        reqVO.setRecordType(StorageRecordTypeEnum.SYSTEM_APPEARANCE.getType());
        reqVO.setRecordId(RECORD_ID);
        List<SystemStorageBlobRespVO> blobs = storageAttachmentService.listAttachments(reqVO);
        return CollUtil.isEmpty(blobs) ? null : blobs.get(0);
    }
    /**
     * å¤–观图需要登录前就能访问,因此使用永久公开地址
     */
    private String buildPermanentUrl(SystemStorageBlobRespVO blob) {
        return storageFileUtil.buildSignedUrl(blob, "/preview/", BigDecimal.valueOf(-1));
    }
    private void saveBlob(StorageApplicationTypeEnum application, Long blobId) {
        SystemStorageAttachmentSaveReqVO reqVO = new SystemStorageAttachmentSaveReqVO();
        reqVO.setApplication(application.getType());
        reqVO.setRecordType(StorageRecordTypeEnum.SYSTEM_APPEARANCE.getType());
        reqVO.setRecordId(RECORD_ID);
        reqVO.setBlobItems(blobId == null
                ? Collections.emptyList()
                : Collections.singletonList(buildBlobItem(blobId, application)));
        storageAttachmentService.bindAttachments(reqVO);
    }
    private SystemStorageAttachmentSaveReqVO.BlobItem buildBlobItem(Long blobId, StorageApplicationTypeEnum application) {
        SystemStorageAttachmentSaveReqVO.BlobItem item = new SystemStorageAttachmentSaveReqVO.BlobItem();
        item.setBlobId(blobId);
        item.setApplication(application.getType());
        return item;
    }
}