云
5 天以前 f02d0df2f1dfc7a8b775cf50d45375e57628a5ef
feat(ncr): 完善NCR处置联动逻辑与关闭校验

- 新增重新生产处置方式并按检验类型过滤可选项
- 完善出货检验返工返修的库存扣减与原单取消联动
- 增加不合格品关闭前的复检单绑定与状态校验逻辑
- 移除检验完成时的附件强制上传校验
- 重构销售合同Word模板渲染为XML节点精确填充
- 将销售报价单导出格式由Word更改为Excel
已添加43个文件
已修改37个文件
3594 ■■■■■ 文件已修改
docs/mes_qc_ncr_integration.md 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/05-quality-flow.mmd 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_mes_qc_ncr_reinspection.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_mes_qc_ncr_return_sales.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_misc_issue_rework_type.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_ncr_reproduction_dict.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_product_produce_ncr_disposition.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_product_sales_ncr_disposition.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_qc_purchase_concession.sql 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_qc_purchase_return.sql 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261110_wm_scrap_return_source.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationDocumentRenderer.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExcelRenderer.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/resources/templates/quotation/sale-quotation.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRendererTest.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExcelRendererTest.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/MesQcNcrController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/MesQcPurchaseReturnController.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/vo/MesQcNcrCloseReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/vo/MesQcNcrDisposeReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/vo/MesQcNcrRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/itemreceipt/vo/line/MesWmItemReceiptLineSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/ncr/MesQcNcrDO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/ncr/MesQcPurchaseConcessionDO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/ncr/MesQcPurchaseReturnDO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productproduce/MesWmProductProduceLineDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesLineDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesLineDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ncr/MesQcNcrMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ncr/MesQcPurchaseConcessionMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ncr/MesQcPurchaseReturnMapper.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/returnsales/MesWmReturnSalesMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/qc/MesNcrDispositionEnum.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/wm/MesWmMiscIssueTypeEnum.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/MesQcCompletionHelper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcArrivalDispositionService.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcConcessionService.java 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReproductionService.java 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReworkService.java 398 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcSourceClaim.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrPurchaseSourceValidator.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrReinspectionValidator.java 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrReturnSalesLink.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrScrapService.java 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrServiceImpl.java 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrStockClosureGuard.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseConcessionService.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseReturnService.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/listener/MesQcNcrStatusListener.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImpl.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptLineServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/miscissue/MesWmMiscIssueServiceImpl.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmOqcConcessionGuard.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesDetailServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcArrivalDispositionServiceTest.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrGuardTest.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcConcessionServiceTest.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReproductionServiceTest.java 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReworkServiceTest.java 250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrPurchaseSourceValidatorTest.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrReinspectionValidatorTest.java 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrScrapServiceTest.java 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrStockClosureGuardTest.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseConcessionServiceTest.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseReturnServiceTest.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/miscissue/MesWmMiscIssueServiceImplTest.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmOqcConcessionGuardTest.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/resources/sql/create_tables.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/mes_qc_ncr_integration.md
@@ -11,7 +11,7 @@
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **检验判定不合格 â†’ NCR åˆ›å»º**:在各检验单详情页,当检验结果为"不合格退货"或"不合格报废"时,可点击"生成NCR"按钮,自动带入检验类型、检验单ID/编号、物料信息、不合格数量、不合格描述,跳转到 NCR æ–°å»ºé¡µé¢ã€‚
2. **NCR å¾…评审 â†’ è¯„审**:填写处置方式(退货/报废/返工/让步接收/降级使用)、责任部门、缺陷等级、评审意见。
2. **NCR å¾…评审 â†’ è¯„审**:按检验类型选择处置方式(IQC é€€è´§/让步放行;IPQC æŠ¥åºŸ/返工/让步放行/返修/重新生产;OQC è¿”å·¥/让步放行/返修/重新生产;RQC æŠ¥åºŸ/返工/让步放行/返修),并填写责任部门、缺陷等级、评审意见。
3. **NCR å·²è¯„审 â†’ å¤„ç½®**:填写处理人、处理结果说明。
4. **NCR å·²å¤„ç½® â†’ å…³é—­**:填写关闭意见。
@@ -65,7 +65,7 @@
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | NCR编号 |
| disposition | Integer | æ˜¯ | å¤„置方式(1=退货/2=报废/3=返工/4=让步接收/5=降级使用) |
| disposition | Integer | æ˜¯ | å¤„置方式(1=退货/2=报废/3=返工/4=让步接收/5=降级使用/6=返修/7=重新生产) |
| responsibleDeptId | Long | å¦ | è´£ä»»éƒ¨é—¨ID |
| defectLevel | Integer | å¦ | ç¼ºé™·ç­‰çº§ |
| reviewOpinion | String | å¦ | è¯„审意见 |
@@ -74,18 +74,28 @@
**PUT** `/mes/qc/ncr/dispose`
处置会联动生成业务单据:让步放行(4)/报废(2) ç”Ÿæˆæ‚项入库单并自动提交、完成入库;返工(3)/返修(6) ç”Ÿæˆè‡ªè¡Œç”Ÿäº§ç±»åž‹ç”Ÿäº§è®¢å•(草稿)。
处置会联动生成业务单据:让步放行(4)/报废(2) ç”Ÿæˆæ‚项入库单并自动提交、完成入库;返工(3)/返修(6) ç”Ÿæˆè‡ªè¡Œç”Ÿäº§ç±»åž‹ç”Ÿäº§è®¢å•(草稿);出货检验(OQC)的返工/返修会扣减原批次成品库存、自动取消原销售出库单并生成已下达的返工工单;重新生产(7) å¤åˆ¶åŽŸå·¥å•å·¥åºä¸Ž BOM ä¸‹è¾¾æ–°å·¥å•。
可选的处置方式取决于检验来源(`qcType`):
| qcType | å¯å¤„置方式 |
|---|---|
| 1 IQC | é€€è´§(1)、让步放行(4) |
| 2 IPQC | æŠ¥åºŸ(2)、返工(3)、让步放行(4)、返修(6)、重新生产(7) |
| 3 OQC | è¿”å·¥(3)、让步放行(4)、返修(6)、重新生产(7) |
| 4 RQC | æŠ¥åºŸ(2)、返工(3)、让步放行(4)、返修(6) |
请求参数:
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | NCR编号 |
| disposition | Integer | æ˜¯ | å¤„置方式(仅 2=报废/3=返工/4=让步放行/6=返修) |
| disposition | Integer | æ˜¯ | å¤„置方式,取值范围见上方「可选处置方式」 |
| warehouseId | Long | æ¡ä»¶å¿…å¡« | å…¥åº“仓库;让步放行/报废时必填,前端默认带出原材料仓 |
| locationId | Long | æ¡ä»¶å¿…å¡« | å…¥åº“库区;让步放行/报废时必填 |
| areaId | Long | æ¡ä»¶å¿…å¡« | å…¥åº“库位;让步放行/报废时必填 |
| batchCode | String | æ¡ä»¶å¿…å¡« | æ‰¹æ¬¡å·ï¼›ç‰©æ–™å¯ç”¨æ‰¹æ¬¡ç®¡ç†æ—¶å¿…填,批次不存在会自动创建 |
| materialStockId | Long | æ¡ä»¶å¿…å¡« | åŽŸæ‰¹æ¬¡æˆå“åº“å­˜è®°å½•ID;OQC è¿”å·¥/返修必填。未传时后端要求同物料同批次仅一条可用库存,否则提示先选择具体库存 |
| handlerUserId | Long | å¦ | å¤„理人ID |
| handleResult | String | å¦ | å¤„理结果说明 |
@@ -126,6 +136,16 @@
|------|------|------|------|
| id | Long | æ˜¯ | NCR编号 |
| closeOpinion | String | å¦ | å…³é—­æ„è§ |
关闭前校验(按处置方式):
| å¤„置方式 | å…³é—­å‰å¿…须满足 |
|---|---|
| è¿”å·¥(3)/返修(6) | å¯¹åº”返工工单已完成并报工;复检单必须绑定该返工工单的报工产出行,检测数量=不合格数量,合格产出有对应入库流水;复检若有不合格数量,须已生成唯一的新 NCR |
| OQC è¿”å·¥(3)/返修(6) | åœ¨ä¸Šä¸€è¡ŒåŸºç¡€ä¸Šï¼šåŽŸé”€å”®å‡ºåº“å•å·²ä¸ºã€Œå·²å–æ¶ˆã€ï¼Œè¿”å·¥é¢†ç”¨å‡ºåº“å•å·²å®Œæˆä¸”å‡ºåº“æ•°é‡=不合格数量 |
| IQC é€€è´§(1) | é‡‡è´­é€€è´§å•已确认退货 |
| OQC è®©æ­¥æ”¾è¡Œ(4) | åŽŸé”€å”®å‡ºåº“å•å·²å®Œæˆæ‹£è´§åŠå‡ºåº“ |
| é‡æ–°ç”Ÿäº§(7) | å½“前版本暂不允许关闭 |
### åˆ†é¡µæŸ¥è¯¢
@@ -215,6 +235,7 @@
| 4 | è®©æ­¥æŽ¥æ”¶ | æ‚项入库到原材料仓 |
| 5 | é™çº§ä½¿ç”¨ | åŽ†å²æžšä¸¾ï¼Œè¯„å®¡ä¸å†å¯é€‰ |
| 6 | è¿”ä¿® | ç”Ÿæˆç”Ÿäº§è®¢å• |
| 7 | é‡æ–°ç”Ÿäº§ | å¤åˆ¶åŽŸå·¥å•å·¥åºåŠ BOM,下达新生产订单 |
### å¤„置联动规则
@@ -222,7 +243,9 @@
|----------|----------|-----------|
| è®©æ­¥æ”¾è¡Œ(4) | æ‚项入库单(type=2 è®©æ­¥æ”¾è¡Œå…¥åº“),自动提交并完成入库,库存增加 | é»˜è®¤åŽŸææ–™ä»“ï¼Œå¯æ”¹ä»“åº“/库区/库位 |
| æŠ¥åºŸ(2) | æ‚项入库单(type=3 æŠ¥åºŸå…¥åº“),自动提交并完成入库,库存增加 | é»˜è®¤ä¸åˆæ ¼å“ä»“,可改仓库/库区/库位 |
| è¿”å·¥(3)/返修(6) | ç”Ÿäº§è®¢å•(自行生产,草稿态) | äº§å“=NCR物料、数量=不合格数量,BOM/条码自动生成,工艺路线需人工补充后下达 |
| è¿”å·¥(3)/返修(6)(IPQC/RQC) | ç”Ÿäº§è®¢å•(自行生产,草稿态) | äº§å“=NCR物料、数量=不合格数量,BOM/条码自动生成,工艺路线需人工补充后下达 |
| è¿”å·¥(3)/返修(6)(OQC) | è¿”工领用出库单(杂项出库 type=3)+ åŽŸé”€å”®å‡ºåº“å•è‡ªåŠ¨å–æ¶ˆ + è¿”工工单(已确认并生成任务) | å¿…须选定原批次成品库存;扣减数量=不合格数量;返工工单复制原工单工艺路线与 BOM |
| é‡æ–°ç”Ÿäº§(7)(IPQC/OQC) | ç”Ÿäº§è®¢å•(自行生产,已确认并生成任务) | æŒ‰ä¸åˆæ ¼æ•°é‡å¤åˆ¶åŽŸå·¥å•çš„å·¥åºå¿«ç…§ä¸Ž BOM,BOM ç”¨é‡æŒ‰æ¯”例换算 |
### ç¼ºé™·ç­‰çº§æžšä¸¾
@@ -244,4 +267,6 @@
  - å·²å¤„置:关闭
  - å·²å…³é—­ï¼šä»…查看
- è¯¦æƒ…页建议分区域展示:不合格品信息、评审信息、处置信息、关闭信息
- çŠ¶æ€å­—å…¸ç±»åž‹ä¸º `mes_ncr_status`,处置方式字典类型为 `mes_ncr_disposition`
- å¤„置弹窗的处置方式需按 `qcType` è¿‡æ»¤ï¼›OQC é€‰æ‹©è¿”å·¥/返修时需展示「返工库存」选择器(`materialStockId`)
- å‡ºè´§æ£€éªŒä¸åˆæ ¼çš„出库行会阻止销售出库单「确认」;必须先在不合格品处理单中完成处置(含让步放行)后才能继续出库
- çŠ¶æ€å­—å…¸ç±»åž‹ä¸º `mes_ncr_status`,处置方式字典类型为 `mes_ncr_disposition`,杂项出库类型字典类型为 `mes_wm_misc_issue_type`
docs/project-business/data/flows.json
@@ -245,7 +245,7 @@
        },
        {
          "stage": "不合格",
          "action": "NCR: å¾…评审(0)→提交BPM审批(2)→审批通过已评审(6)/驳回回待评审→质量部处置(让步放行/返修/返工/报废)→已处置(7)→关闭(8);处置联动:让步放行/报废生成杂项入库单并自动提交完成入库(处置弹窗选仓库/库区/库位,让步放行默认原材料仓、报废默认不合格品仓,批次管理物料必填批次号),返工/返修生成自行生产类型生产订单(产品=NCR物料、数量=不合格数量、BOM与条码自动生成、工艺路线人工补充);审批意见由 BPM æµè½¬è®°å½•承载,未发布流程定义或全局免审批时提交后自动通过",
          "action": "NCR: å¾…评审(0)→提交BPM审批(2)→审批通过已评审(6)/驳回回待评审→质量部处置→已处置(7)→关闭(8);处置项按检验类型展示(IQC退货/让步放行,IPQC报废/返工/让步放行/返修/重新生产,OQC返工/让步放行/返修/重新生产,RQC报废/返工/让步放行/返修);让步放行/报废生成杂项入库单并自动提交完成入库(处置弹窗选仓库/库区/库位,让步放行默认原材料仓、报废默认不合格品仓,批次管理物料必填批次号),IPQC/RQC返工/返修生成自行生产类型生产订单(产品=NCR物料、数量=不合格数量、BOM与条码自动生成、工艺路线人工补充),OQC返工/返修先选定原批次成品库存扣减、生成返工领用出库单(type=3)并完成出库、原销售出库单整单自动取消,再复用原工单工序与BOM克隆返工工单并下达,重新生产按不合格数量复制原工单工序与BOM下达新工单;审批意见由 BPM æµè½¬è®°å½•承载,未发布流程定义或全局免审批时提交后自动通过",
          "roles": [
            "质量工程师"
          ]
docs/project-business/data/objects.json
@@ -701,12 +701,12 @@
      "name": "NCR不合格品",
      "module": "质量",
      "table": "mes_qc_ncr",
      "doc": "待评审0→提交BPM审批中2→审批通过已评审6(驳回/取消回待评审)→已处置7→已关闭8;审批意见由 BPM æµè½¬è®°å½•承载,质量部最终处置:让步放行/返修/返工/报废(退货/降级仅历史展示)",
      "doc": "待评审0→提交BPM审批中2→审批通过已评审6(驳回/取消回待评审)→已处置7→已关闭8;审批意见由 BPM æµè½¬è®°å½•承载;最终处置按检验类型动态可选:IQC退货/让步放行,IPQC报废/返工/让步放行/返修/重新生产,OQC返工/让步放行/返修/重新生产,RQC报废/返工/让步放行/返修(降级仅历史展示);OQC返工/返修需选定原批次成品库存,扣减并生成返工领用出库单、自动取消原销售出库单后克隆返工工单,关闭前核验返工完成、出库取消与领用数量",
      "upstream": [
        "IQC/IPQC/OQC/RQC"
      ],
      "downstream": [
        "退货/报废/返工(台账)"
        "采购退货单/杂项入库单/返工领用出库单/生产订单(台账)"
      ],
      "keywords": "NCR ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"
    },
docs/project-business/mermaid/05-quality-flow.mmd
@@ -34,9 +34,15 @@
  N1A -->|BPM é©³å›ž/取消·保留流程实例可查意见| N1
  N1A -->|BPM å®¡æ‰¹é€šè¿‡Â·å®¡æ‰¹èŠ‚ç‚¹ã€Œè´¨é‡éƒ¨é—¨å®¡æ‰¹ã€æŒ‰è´¨é‡éƒ¨é—¨è§’è‰²æˆ–ç­¾Â·æ„è§ç”± BPM æµè½¬è®°å½•承载| N2[已评审6·质量部待处置]
  N2 -->|质量部最终处置·让步放行4/报废2| N3A[生成杂项入库单·自动提交并完成入库·处置弹窗选仓库/库区/库位·让步放行默认原材料仓·报废默认不合格品仓·批次管理物料必填批次号]
  N2 -->|质量部最终处置·返工3/返修6| N3B[生成自行生产类型生产订单(草稿)·产品=NCR物料·数量=不合格数量·BOM与条码自动生成·工艺路线人工补充后下达]
  N2 -->|质量部最终处置·过程/退货检验(IPQC/RQC)返工3/返修6| N3B[生成自行生产类型生产订单(草稿)·产品=NCR物料·数量=不合格数量·BOM与条码自动生成·工艺路线人工补充后下达]
  N2 -->|质量部最终处置·出货检验(OQC)返工3/返修6| N3C[选定原批次成品库存并扣减·生成返工领用出库单type=3并完成出库·原销售出库单整单自动取消·复用原工单工序与BOM克隆返工工单并下达]
  N2 -->|质量部最终处置·重新生产7(IPQC/OQC)| N3D[按不合格数量复制原工单工序快照与BOM·生成并确认新生产订单·自动生成任务草稿]
  N3A --> N3[已处置7]
  N3B --> N3
  N3C --> N3
  N3D --> N3
  N3C -.->|关闭前核验| N4C[返工工单已完成·原销售出库单已取消·返工领用出库流水数量=不合格数量·复检绑定返工报工产出行]
  N2 -.->|处置项按检验类型展示·IQC退货/让步·IPQC报废/返工/让步/返修/重新生产·OQC返工/让步/返修/重新生产·RQC报废/返工/让步/返修| N3E[前后端同源过滤]
  N3 -->|关闭| N4[已关闭8]
  N1 -.->|未发布流程定义或全局免审批时·提交后自动置为已评审| N2
  Q8[支撑数据:质检方案·检验指标·缺陷库·指标结果] --- A1
sql/mysql/20261110_mes_qc_ncr_reinspection.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
-- è¿”å·¥/返修关闭时记录复检证据;部署新代码前执行。
ALTER TABLE `mes_qc_ncr`
    ADD COLUMN `reinspection_ipqc_id` bigint NULL COMMENT '返工/返修关闭时的复检单 ID';
sql/mysql/20261110_mes_qc_ncr_return_sales.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
-- å®¢æˆ·é€€è´§ä¸åˆæ ¼è¡Œè½¬äº¤ NCR,避免原退货单再次入库。
ALTER TABLE `mes_wm_return_sales_line`
    ADD COLUMN `ncr_disposition_id` bigint NULL COMMENT '承接不合格数量的 NCR ID';
sql/mysql/20261110_misc_issue_rework_type.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,4 @@
INSERT INTO `system_dict_data`
(`sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 3, '返工领用', '3', 'mes_wm_misc_issue_type', 0, 'warning', '', '返工领用出库', '1', NOW(), '1', NOW(), b'0'
WHERE NOT EXISTS (SELECT 1 FROM `system_dict_data` WHERE `dict_type` = 'mes_wm_misc_issue_type' AND `value` = '3' AND `deleted` = b'0');
sql/mysql/20261110_ncr_reproduction_dict.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,4 @@
INSERT INTO `system_dict_data`
(`sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 7, '重新生产', '7', 'mes_ncr_disposition', 0, '', '', '出货不合格重新生产', '1', NOW(), '1', NOW(), b'0'
WHERE NOT EXISTS (SELECT 1 FROM `system_dict_data` WHERE `dict_type` = 'mes_ncr_disposition' AND `value` = '7' AND `deleted` = b'0');
sql/mysql/20261110_product_produce_ncr_disposition.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
-- ä¸åˆæ ¼äº§å‡ºè¡Œè¢« NCR æ¶ˆè´¹åŽä¿ç•™å¤„置关联,防止多个 NCR é‡å¤å…¥åº“。
ALTER TABLE `mes_wm_product_produce_line`
    ADD COLUMN `ncr_disposition_id` bigint NULL COMMENT '处置该不合格产出的 NCR ID';
sql/mysql/20261110_product_sales_ncr_disposition.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
-- å‡ºè´§ä¸åˆæ ¼è¡Œåªèƒ½è½¬äº¤ä¸€å¼ å¤„理单。
ALTER TABLE `mes_wm_product_sales_line`
    ADD COLUMN `ncr_disposition_id` bigint NULL COMMENT '承接不合格出货的 NCR ID';
sql/mysql/20261110_qc_purchase_concession.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
-- è®°å½•已按采购入库过账的让步数量,后续到货入库只收原合格数量。
CREATE TABLE IF NOT EXISTS `mes_qc_purchase_concession` (
 `id` bigint NOT NULL AUTO_INCREMENT,
 `ncr_id` bigint NOT NULL,
 `iqc_id` bigint NOT NULL,
 `notice_id` bigint NOT NULL,
 `notice_line_id` bigint NOT NULL,
 `purchase_order_item_id` bigint NOT NULL,
 `receipt_id` bigint NOT NULL,
 `quantity` decimal(20,4) NOT NULL,
 `creator` varchar(64) DEFAULT '', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
 `updater` varchar(64) DEFAULT '', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 `deleted` bit(1) NOT NULL DEFAULT b'0',
 `tenant_id` bigint NOT NULL DEFAULT 0,
 PRIMARY KEY (`id`), UNIQUE KEY `uk_qc_purchase_concession_ncr` (`ncr_id`),
 UNIQUE KEY `uk_qc_purchase_concession_iqc` (`iqc_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='来料让步采购入库关联';
sql/mysql/20261110_qc_purchase_return.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
-- æœªå…¥åº“不合格品的直接采购退货;不生成库存出入库,也不占用已入库退货数量。
CREATE TABLE IF NOT EXISTS `mes_qc_purchase_return` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `code` varchar(64) NOT NULL,
  `ncr_id` bigint NOT NULL,
  `iqc_id` bigint NOT NULL,
  `notice_id` bigint NOT NULL,
  `notice_line_id` bigint NOT NULL,
  `purchase_order_id` bigint NOT NULL,
  `purchase_order_item_id` bigint NOT NULL,
  `vendor_id` bigint NOT NULL,
  `item_id` bigint NOT NULL,
  `vendor_batch` varchar(100) DEFAULT NULL,
  `quantity` decimal(20,4) NOT NULL,
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待仓库退货 1已确认退货',
  `confirmed_by` bigint DEFAULT NULL,
  `confirmed_at` datetime DEFAULT NULL,
  `confirmation_remark` varchar(500) DEFAULT NULL,
  `creator` varchar(64) DEFAULT '',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT '',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  `tenant_id` bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`), UNIQUE KEY `uk_qc_purchase_return_ncr` (`ncr_id`),
  UNIQUE KEY `uk_qc_purchase_return_iqc` (`iqc_id`),
  KEY `idx_qc_purchase_return_order_item` (`purchase_order_item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待入库来料不合格采购退货';
sql/mysql/20261110_wm_scrap_return_source.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,4 @@
-- å®¢æˆ·é€€è´§äº§ç”Ÿçš„废料没有生产工单,以废料单编码及备注追溯不合格处理单。
ALTER TABLE `mes_wm_scrap_receipt`
    MODIFY COLUMN `work_order_id` bigint NULL COMMENT '生产工单编号;客户退货为空',
    MODIFY COLUMN `work_order_code` varchar(64) NULL COMMENT '生产工单号快照';
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java
@@ -18,7 +18,8 @@
import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.service.quotation.CrmSaleQuotationDocumentRenderer;
import cn.iocoder.yudao.module.crm.service.quotation.CrmSaleQuotationExcelRenderer;
import cn.iocoder.yudao.module.crm.service.contact.CrmContactService;
import cn.iocoder.yudao.module.crm.service.quotation.CrmSaleQuotationService;
import cn.iocoder.yudao.module.system.service.companyconfig.SystemCompanyConfigService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
@@ -76,6 +77,8 @@
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private CrmCustomerService customerService;
    @Resource
    private CrmContactService contactService;
    @Resource
    private CrmBusinessService businessService;
    @Resource
@@ -166,8 +169,8 @@
        ExcelUtils.write(response, "销售报价单.xls", "数据", CrmSaleQuotationRespVO.class, list);
    }
    @GetMapping("/export-word")
    @Operation(summary = "导出销售报价单 Word(带电子章)")
    @GetMapping({"/export-word", "/export-quotation-excel"})
    @Operation(summary = "导出销售报价单 Excel(带电子章)")
    @Parameter(name = "id", description = "报价单编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:export')")
    @ApiAccessLog(operateType = EXPORT)
@@ -187,12 +190,17 @@
                saleQuotationService.getSaleQuotationProductListByQuotationId(id));
        var companyConfig = companyConfigService.getCompanyConfig();
        String companyName = companyConfig != null ? companyConfig.getCompanyName() : null;
        byte[] content = CrmSaleQuotationDocumentRenderer.render(respVO, companyName);
        var customer = saleQuotation.getCustomerId() == null ? null : customerService.getCustomer(saleQuotation.getCustomerId());
        var contact = saleQuotation.getContactId() == null ? null : contactService.getContact(saleQuotation.getContactId());
        String phone = contact == null ? null : (contact.getMobile() != null && !contact.getMobile().isBlank()
                ? contact.getMobile() : contact.getTelephone());
        byte[] content = CrmSaleQuotationExcelRenderer.render(respVO, companyName,
                customer == null ? null : customer.getDetailAddress(), contact == null ? null : contact.getName(), phone);
        String filename = "报价单-" + saleQuotation.getNo()
                + (respVO.getCustomerName() != null ? "-" + respVO.getCustomerName() : "") + ".docx";
                + (respVO.getCustomerName() != null ? "-" + respVO.getCustomerName() : "") + ".xlsx";
        String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
        response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + encoded);
        response.getOutputStream().write(content);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java
@@ -4,6 +4,7 @@
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO;
import org.springframework.core.io.ClassPathResource;
import org.w3c.dom.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.TransformerFactory;
@@ -16,7 +17,7 @@
import java.util.*;
import java.util.zip.*;
/** ä¿ç•™ç”¨æˆ·åˆåŒæ¨¡æ¿çš„ OOXML åŒ…,仅填写业务槽位并扩展产品明细行。 */
/** åœ¨é”€å”®åˆåŒæ¨¡æ¿ä¸­å¡«å†™åˆåŒä¿¡æ¯ï¼Œä¿ç•™æ¨¡æ¿çš„其他内容及格式。 */
public final class CrmContractDocumentRenderer {
    private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private CrmContractDocumentRenderer() { }
@@ -49,14 +50,38 @@
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
            var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
            Node rowTemplate = null;
            var rows = document.getElementsByTagNameNS(W, "tr");
            for (int i = 0; i < rows.getLength(); i++) {
                if (rows.item(i).getTextContent().contains("${itemName}")) { rowTemplate = rows.item(i); break; }
            var paragraphs = document.getElementsByTagNameNS(W, "p");
            boolean numberFilled = false, dateFilled = false, buyerFilled = false;
            for (int i = 0; i < paragraphs.getLength(); i++) {
                Node p = paragraphs.item(i);
                String value = p.getTextContent();
                if (value.contains("合同编号:SHHT20260102902-F")) {
                    setParagraphText(p, "合同编号:" + text(contract.getNo()));
                    alignRight(p);
                    numberFilled = true;
                } else if (value.trim().equals("签订日期:")) {
                    setParagraphText(p, "签订日期:" + date(contract.getOrderDate()));
                    alignRight(p);
                    dateFilled = true;
                } else if (value.contains("买方(甲方):") && value.contains("签订地点:江苏南通")) {
                    fillBuyer(p, text(contract.getCustomerName()));
                    buyerFilled = true;
                } else if (value.equals("乙方(盖章):")) {
                    // åŽŸæ¨¡æ¿çš„ç©ºç™½ä¹°æ–¹ç­¾ç« æ¡†è¯¯å†™ä¸ºä¹™æ–¹ï¼›åŒæ—¶å¤„ç† DrawingML å’Œ VML ä¸¤ä¸ªå…¼å®¹åˆ†æ”¯ã€‚
                    setParagraphText(p, "甲方(盖章):" + text(contract.getCustomerName()));
                }
            }
            if (rowTemplate == null) throw new IOException("合同模板缺少产品明细槽位");
            int index = 0;
            if (!numberFilled || !dateFilled || !buyerFilled) throw new IOException("销售合同模板表头不完整");
            Node table = document.getElementsByTagNameNS(W, "tbl").item(0);
            List<Node> rows = children(table, "tr");
            if (rows.size() != 4 || children(rows.get(1), "tc").size() != 7
                    || !rows.get(3).getTextContent().contains("合计(含13%增值税)")) {
                throw new IOException("销售合同模板产品表格不完整");
            }
            Node rowTemplate = rows.get(1);
            BigDecimal productTotal = BigDecimal.ZERO;
            int index = 0;
            for (var product : contract.getProducts()) {
                Node row = rowTemplate.cloneNode(true);
                BigDecimal price = Objects.requireNonNull(product.getContractPrice(), "合同单价不能为空");
@@ -66,25 +91,32 @@
                productTotal = productTotal.add(amount);
                String name = text(product.getItemName());
                if (name.isBlank()) name = text(product.getItemCode());
                String specification = text(product.getItemSpecification());
                if (!specification.isBlank()) name += "(" + specification + ")";
                replace(row, Map.of("index", String.valueOf(++index), "itemName", name,
                        "unitName", text(product.getItemUnitName()), "count", count.stripTrailingZeros().toPlainString(),
                        "price", money(price), "amount", money(amount)));
                List<Node> cells = children(row, "tc");
                String[] values = {String.valueOf(++index), name, text(product.getItemSpecification()),
                        text(product.getItemUnitName()), count.stripTrailingZeros().toPlainString(), money(price), money(amount)};
                for (int i = 0; i < values.length; i++) {
                    Node cell = cells.get(i);
                    Node properties = firstChild(cell, "tcPr");
                    Node noWrap = firstChild(properties, "noWrap");
                    if (noWrap != null) properties.removeChild(noWrap);
                    setParagraphText(firstChild(cell, "p"), values[i]);
                }
                Node rowProperties = firstChild(row, "trPr");
                if (firstChild(rowProperties, "cantSplit") == null) {
                    rowProperties.appendChild(document.createElementNS(W, "w:cantSplit"));
                }
                rowTemplate.getParentNode().insertBefore(row, rowTemplate);
            }
            rowTemplate.getParentNode().removeChild(rowTemplate);
            BigDecimal total = Objects.requireNonNull(contract.getTotalPrice(), "合同总金额不能为空");
            BigDecimal discount = productTotal.subtract(total);
            replace(document, Map.of("customerName", text(contract.getCustomerName()),
                    "contractNo", text(contract.getNo()),
                    "contractName", text(contract.getName()),
                    "contractDate", contract.getOrderDate() == null ? "____________" : contract.getOrderDate().format(DateTimeFormatter.ofPattern("yyyyå¹´MM月dd日")),
                    "startDate", date(contract.getStartTime()),
                    "endDate", date(contract.getEndTime()),
                    "totalPrice", money(total), "totalUpper", Convert.digitToChinese(total),
                    "discountNote", discount.signum() == 0 ? "" : "(明细合计 " + money(productTotal) + " å…ƒï¼Œä¸ŽåˆåŒé‡‘额的差额 " + money(discount) + " å…ƒï¼‰"));
            if (document.getDocumentElement().getTextContent().contains("${")) throw new IOException("合同模板存在未填写槽位");
            String summary = "合计(含13%增值税)  äººæ°‘币:" + money(total) + "元  å¤§å†™ï¼š" + Convert.digitToChinese(total);
            if (discount.signum() != 0) summary += "(明细合计 " + money(productTotal) + " å…ƒï¼Œä¸ŽåˆåŒé‡‘额的差额 " + money(discount) + " å…ƒï¼‰";
            Node totalCell = children(rows.get(3), "tc").get(0);
            Node totalProperties = firstChild(totalCell, "tcPr");
            Node noWrap = firstChild(totalProperties, "noWrap");
            if (noWrap != null) totalProperties.removeChild(noWrap);
            setParagraphText(firstChild(totalCell, "p"), summary);
            var transformerFactory = TransformerFactory.newInstance();
            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
@@ -95,16 +127,60 @@
        } catch (Exception exception) { throw new IOException("生成合同 Word æ–‡ä»¶å¤±è´¥", exception); }
    }
    private static void replace(Node node, Map<String, String> values) {
        if (node.getNodeType() == Node.ELEMENT_NODE && "t".equals(node.getLocalName()) && W.equals(node.getNamespaceURI())) {
    private static void fillBuyer(Node paragraph, String buyer) {
        var nodes = ((Element) paragraph).getElementsByTagNameNS(W, "t");
        boolean afterColon = false, inserted = false;
        for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            String value = node.getTextContent();
            for (var entry : values.entrySet()) value = value.replace("${" + entry.getKey() + "}", entry.getValue());
            node.setTextContent(value);
            return;
            if (value.contains("签订地点:")) break;
            if (afterColon) {
                node.setTextContent(inserted ? "" : buyer + "  ");
                inserted = true;
            } else if (value.equals(":")) afterColon = true;
        }
        for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) replace(child, values);
        if (!inserted) throw new IllegalStateException("合同模板缺少买方填写位置");
    }
    private static void alignRight(Node paragraph) {
        Node properties = firstChild(paragraph, "pPr");
        Node indent = firstChild(properties, "ind");
        if (indent != null) properties.removeChild(indent);
        Node alignment = firstChild(properties, "jc");
        if (alignment == null) {
            alignment = paragraph.getOwnerDocument().createElementNS(W, "w:jc");
            properties.appendChild(alignment);
        }
        ((Element) alignment).setAttributeNS(W, "w:val", "right");
    }
    private static void setParagraphText(Node paragraph, String value) {
        var nodes = ((Element) paragraph).getElementsByTagNameNS(W, "t");
        if (nodes.getLength() == 0) {
            var document = paragraph.getOwnerDocument();
            Node run = document.createElementNS(W, "w:r");
            Node text = document.createElementNS(W, "w:t");
            run.appendChild(text);
            paragraph.appendChild(run);
            text.setTextContent(value);
        } else {
            nodes.item(0).setTextContent(value);
            for (int i = 1; i < nodes.getLength(); i++) nodes.item(i).setTextContent("");
        }
    }
    private static List<Node> children(Node parent, String localName) {
        List<Node> result = new ArrayList<>();
        if (parent == null) return result;
        for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) {
            if (W.equals(node.getNamespaceURI()) && localName.equals(node.getLocalName())) result.add(node);
        }
        return result;
    }
    private static Node firstChild(Node parent, String localName) {
        List<Node> result = children(parent, localName);
        return result.isEmpty() ? null : result.get(0);
    }
    private static String money(BigDecimal value) { return value.setScale(2, RoundingMode.HALF_UP).toPlainString(); }
    private static String date(java.time.LocalDateTime value) { return value == null ? "____________" : value.format(DateTimeFormatter.ofPattern("yyyyå¹´MM月dd日")); }
    private static String text(String value) { return value == null ? "" : value; }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationDocumentRenderer.java
@@ -163,7 +163,7 @@
     * æŒ‰å…¬å¸åç§°å®žæ—¶ç»˜åˆ¶çº¢è‰²åœ†å½¢ç”µå­ç« ï¼šåŒåœ†çޝ + ä¸Šæ–¹çŽ¯ç»•å…¬å¸å + ä¸­å¤®äº”角星 + ä¸‹æ–¹â€œæŠ¥ä»·ä¸“用章”。
     * ä¸ä¾èµ–任何静态章图片,避免换公司品牌后仍需单独维护图片资源。
     */
    private static byte[] renderSeal(String companyName) throws IOException {
    static byte[] renderSeal(String companyName) throws IOException {
        int size = 400;
        var image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = image.createGraphics();
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExcelRenderer.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationRespVO;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.core.io.ClassPathResource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
/** ä½¿ç”¨å®¢æˆ·æä¾›çš„ Excel æ¨¡æ¿ç”Ÿæˆå•张销售报价单。 */
public final class CrmSaleQuotationExcelRenderer {
    private static final String TEMPLATE = "templates/quotation/sale-quotation.xlsx";
    private static final String DEFAULT_COMPANY = "苏珩线缆南通有限公司";
    private CrmSaleQuotationExcelRenderer() {
    }
    public static byte[] render(CrmSaleQuotationRespVO quotation, String companyName,
                                String address, String contactName, String contactPhone) throws IOException {
        if (quotation == null || quotation.getItems() == null || quotation.getItems().isEmpty()) {
            throw new IllegalArgumentException("报价单不存在或没有产品明细,无法生成报价文件");
        }
        String company = companyName == null || companyName.isBlank() ? DEFAULT_COMPANY : companyName;
        try (var input = new ClassPathResource(TEMPLATE).getInputStream();
             var workbook = new XSSFWorkbook(input);
             var output = new ByteArrayOutputStream()) {
            var sheet = workbook.getSheetAt(0);
            int extraRows = quotation.getItems().size() - 1;
            if (extraRows > 0) {
                sheet.shiftRows(11, sheet.getLastRowNum(), extraRows, true, false);
            }
            var templateRow = sheet.getRow(10);
            for (int i = 0; i < quotation.getItems().size(); i++) {
                var row = i == 0 ? templateRow : sheet.createRow(10 + i);
                if (i > 0) {
                    row.setHeight(templateRow.getHeight());
                    for (int col = 0; col < 8; col++) {
                        row.createCell(col).setCellStyle(templateRow.getCell(col).getCellStyle());
                    }
                }
                var item = quotation.getItems().get(i);
                string(row.getCell(0), item.getItemName() == null || item.getItemName().isBlank()
                        ? item.getItemCode() : item.getItemName());
                string(row.getCell(1), item.getItemSpecification());
                string(row.getCell(2), item.getItemUnitName());
                number(row.getCell(3), item.getCount());
                number(row.getCell(4), item.getQuotationPrice());
                BigDecimal amount = item.getTotalPrice();
                if (amount == null && item.getCount() != null && item.getQuotationPrice() != null) {
                    amount = item.getCount().multiply(item.getQuotationPrice());
                }
                number(row.getCell(5), amount);
                string(row.getCell(6), null); // è°ƒä»·æ²¡æœ‰å¯¹åº”的系统字段
                string(row.getCell(7), item.getRemark());
            }
            string(sheet.getRow(0).getCell(0), company);
            string(sheet.getRow(2).getCell(0), "编号:" + text(quotation.getNo()));
            string(sheet.getRow(4).getCell(1), quotation.getCustomerName());
            string(sheet.getRow(5).getCell(1), address);
            string(sheet.getRow(6).getCell(1), contactName);
            string(sheet.getRow(7).getCell(1), contactPhone);
            string(sheet.getRow(13 + extraRows).getCell(6), "报价单位:" + company);
            string(sheet.getRow(14 + extraRows).getCell(6), "联  ç³» äººï¼š" + text(quotation.getOwnerUserName()));
            string(sheet.getRow(15 + extraRows).getCell(6), "报价日期:" + (quotation.getQuotationTime() == null
                    ? "" : quotation.getQuotationTime().format(DateTimeFormatter.ofPattern("yyyyå¹´MM月dd日"))));
            workbook.setPrintArea(0, 0, 7, 0, 17 + extraRows);
            int picture = workbook.addPicture(CrmSaleQuotationDocumentRenderer.renderSeal(company), Workbook.PICTURE_TYPE_PNG);
            var anchor = workbook.getCreationHelper().createClientAnchor();
            anchor.setCol1(3);
            anchor.setCol2(5);
            anchor.setRow1(13 + extraRows);
            anchor.setRow2(17 + extraRows);
            sheet.createDrawingPatriarch().createPicture(anchor, picture);
            workbook.write(output);
            return output.toByteArray();
        }
    }
    private static void string(Cell cell, String value) {
        cell.setCellValue(text(value));
    }
    private static void number(Cell cell, BigDecimal value) {
        if (value == null) {
            cell.setBlank();
        } else {
            cell.setCellValue(value.doubleValue());
        }
    }
    private static String text(String value) {
        return value == null ? "" : value;
    }
}
yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx
Binary files differ
yudao-module-crm/src/main/resources/templates/quotation/sale-quotation.xlsx
Binary files differ
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRendererTest.java
@@ -36,8 +36,9 @@
        String xml = new String(parts.get("word/document.xml"), StandardCharsets.UTF_8);
        assertTrue(xml.contains("HT20261015001")); assertTrue(xml.contains("2026å¹´10月15日"));
        assertTrue(xml.contains("10000.00")); assertTrue(xml.contains("壹万元整"));
        assertTrue(xml.contains("冻干冬虫夏草(2500级一等)")); assertTrue(xml.contains("交付与验收"));
        assertFalse(xml.contains("北京同仁堂")); assertFalse(xml.contains("28504001040001454"));
        assertTrue(xml.contains("冻干冬虫夏草")); assertTrue(xml.contains("2500级一等")); assertTrue(xml.contains("交货条款"));
        assertTrue(xml.contains("质量异议期限")); assertFalse(xml.contains("SHHT20260102902-F"));
        assertTrue(xml.contains("甲方(盖章):中国科学院西北高原生物研究所"));
        assertFalse(xml.contains("${"));
        try(var template = new ClassPathResource("templates/contract/sale-contract.docx").getInputStream()) {
            var original = unzip(template.readAllBytes()); assertEquals(original.keySet(),parts.keySet());
@@ -52,10 +53,10 @@
        contract.setTotalPrice(new BigDecimal("249000"));
        String xml = new String(unzip(CrmContractDocumentRenderer.render(contract)).get("word/document.xml"),StandardCharsets.UTF_8);
        assertTrue(xml.contains("客户&lt;&amp;&gt;公司")); assertTrue(xml.contains("与合同金额的差额 1000.00 å…ƒ"));
        assertEquals(25,xml.split("冻干冬虫夏草(2500级一等)",-1).length-1);
        assertEquals(25,xml.split("冻干冬虫夏草",-1).length-1);
        assertEquals(25,xml.split("<w:cantSplit",-1).length-1);
        assertTrue(xml.contains("249000.00")); assertFalse(xml.contains("${"));
        assertTrue(xml.contains("乙方(需方):客户&lt;&amp;&gt;公司"));
        assertTrue(xml.contains("甲方(盖章):客户&lt;&amp;&gt;公司"));
        String output = System.getProperty("contract.document.sample");
        if (output != null) Files.write(Path.of(output).resolveSibling("generated-contract-many.docx"), CrmContractDocumentRenderer.render(contract));
    }
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationExcelRendererTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationRespVO;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class CrmSaleQuotationExcelRendererTest {
    @Test
    void fillsMultipleItemsAndPreservesTemplateTermsAndPrintArea() throws Exception {
        var quote = new CrmSaleQuotationRespVO();
        quote.setNo("BJ-123");
        quote.setCustomerName("客户甲");
        quote.setOwnerUserName("销售员");
        quote.setQuotationTime(LocalDateTime.of(2026, 11, 10, 0, 0));
        var first = new CrmSaleQuotationRespVO.Item();
        first.setItemName("电缆A");
        first.setCount(new BigDecimal("2.5"));
        first.setQuotationPrice(new BigDecimal("3.20"));
        var second = new CrmSaleQuotationRespVO.Item();
        second.setItemName("电缆B");
        second.setRemark("备注B");
        quote.setItems(List.of(first, second));
        byte[] bytes = CrmSaleQuotationExcelRenderer.render(quote, "测试公司", "客户地址", "联系人", "123456");
        try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
            var sheet = workbook.getSheetAt(0);
            assertEquals("编号:BJ-123", sheet.getRow(2).getCell(0).getStringCellValue());
            assertEquals("客户甲", sheet.getRow(4).getCell(1).getStringCellValue());
            assertEquals("客户地址", sheet.getRow(5).getCell(1).getStringCellValue());
            assertEquals("联系人", sheet.getRow(6).getCell(1).getStringCellValue());
            assertEquals("123456", sheet.getRow(7).getCell(1).getStringCellValue());
            assertEquals(2.5, sheet.getRow(10).getCell(3).getNumericCellValue());
            assertEquals(8.0, sheet.getRow(10).getCell(5).getNumericCellValue());
            assertEquals("电缆B", sheet.getRow(11).getCell(0).getStringCellValue());
            assertEquals("备注B", sheet.getRow(11).getCell(7).getStringCellValue());
            String terms = sheet.getRow(13).getCell(0).getStringCellValue();
            assertTrue(terms.contains("110元/公斤"));
            assertTrue(terms.contains("货到45天内付款"));
            assertEquals("报价日期:2026å¹´11月10日", sheet.getRow(16).getCell(6).getStringCellValue());
            assertTrue(workbook.getPrintArea(0).contains("$H$19"));
            assertEquals(2, workbook.getAllPictures().size());
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/MesQcNcrController.java
@@ -97,7 +97,7 @@
    @Operation(summary = "关闭不合格品处理单")
    @PreAuthorize("@ss.hasPermission('mes:qc-ncr:close')")
    public CommonResult<Boolean> closeNcr(@Valid @RequestBody MesQcNcrCloseReqVO closeReqVO) {
        ncrService.closeNcr(closeReqVO.getId(), closeReqVO.getCloseOpinion());
        ncrService.closeNcr(closeReqVO.getId(), closeReqVO.getCloseOpinion(), closeReqVO.getReinspectionIpqcId());
        return success(true);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/MesQcPurchaseReturnController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.ncr;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseReturnDO;
import cn.iocoder.yudao.module.mes.service.qc.ncr.MesQcPurchaseReturnService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - æ¥æ–™ä¸åˆæ ¼é‡‡è´­é€€è´§")
@RestController
@RequestMapping("/mes/qc-purchase-return")
public class MesQcPurchaseReturnController {
    @Resource private MesQcPurchaseReturnService service;
    @GetMapping("/get-by-ncr")
    @Operation(summary = "按不合格单查看采购退货单")
    @PreAuthorize("@ss.hasPermission('mes:qc-ncr:query') or @ss.hasPermission('mes:wm-return-vendor:finish')")
    public CommonResult<MesQcPurchaseReturnDO> getByNcr(@RequestParam Long ncrId) {
        return success(service.getByNcrId(ncrId));
    }
    @GetMapping("/page")
    @Operation(summary = "采购仓库查询待退和已退单据")
    @PreAuthorize("@ss.hasPermission('mes:wm-return-vendor:finish')")
    public CommonResult<PageResult<MesQcPurchaseReturnDO>> page(PageParam page, @RequestParam(required = false) Integer status) {
        return success(service.getPage(page, status));
    }
    @PutMapping("/confirm")
    @Operation(summary = "采购仓库确认已退回供应商")
    @PreAuthorize("@ss.hasPermission('mes:wm-return-vendor:finish')")
    public CommonResult<Boolean> confirm(@RequestParam Long id, @RequestParam(required = false) String remark) {
        service.confirm(id, remark);
        return success(true);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/vo/MesQcNcrCloseReqVO.java
@@ -15,4 +15,7 @@
    @Schema(description = "关闭意见", example = "处置完成,予以关闭")
    private String closeOpinion;
    @Schema(description = "返工或返修完成后的整批复检过程检验单 ID")
    private Long reinspectionIpqcId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/vo/MesQcNcrDisposeReqVO.java
@@ -12,8 +12,8 @@
    @NotNull(message = "NCR编号不能为空")
    private Long id;
    @Schema(description = "处置方式(2-报废 3-返工 4-让步放行 6-返修)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
    @NotNull(message = "处置方式不能为空,请从[让步放行、返修、返工、报废]中选择一项")
    @Schema(description = "处置方式:1退货、2报废、3返工、4让步放行、6返修、7重新生产;可选项取决于检验来源", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
    @NotNull(message = "处置方式不能为空")
    private Integer disposition;
    @Schema(description = "处理人ID", example = "2")
@@ -34,4 +34,7 @@
    @Schema(description = "批次号(物料启用批次管理时必填,不存在会自动创建批次)", example = "BATCH20261109001")
    private String batchCode;
    @Schema(description = "出货让步放行或返工/返修时选定的实际库存记录 ID")
    private Long materialStockId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/ncr/vo/MesQcNcrRespVO.java
@@ -13,6 +13,9 @@
@ExcelIgnoreUnannotated
public class MesQcNcrRespVO {
    @Schema(description = "返工或返修关闭时使用的复检单 ID")
    private Long reinspectionIpqcId;
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @ExcelProperty("编号")
    private Long id;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/itemreceipt/vo/line/MesWmItemReceiptLineSaveReqVO.java
@@ -43,6 +43,9 @@
    @Schema(description = "生产批号", example = "PB20260110")
    private String lotNumber;
    @Schema(description = "指定库存批次号;为空时按规则生成")
    private String batchCode;
    @Schema(description = "备注", example = "备注")
    private String remark;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/ncr/MesQcNcrDO.java
@@ -155,6 +155,9 @@
     */
    private String closeOpinion;
    /** å…³é—­è¿”å·¥/返修单时采用的复检证据。 */
    private Long reinspectionIpqcId;
    // ========== çŠ¶æ€ä¸Žæµç¨‹ ==========
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/ncr/MesQcPurchaseConcessionDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mes_qc_purchase_concession")
public class MesQcPurchaseConcessionDO extends BaseDO {
    @TableId private Long id;
    private Long ncrId;
    private Long iqcId;
    private Long noticeId;
    private Long noticeLineId;
    private Long purchaseOrderItemId;
    private Long receiptId;
    private BigDecimal quantity;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/ncr/MesQcPurchaseReturnDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** æœªå…¥åº“的来料不合格品直接退回供应商,不产生库存出库。 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mes_qc_purchase_return")
public class MesQcPurchaseReturnDO extends BaseDO {
    @TableId private Long id;
    private String code;
    private Long ncrId;
    private Long iqcId;
    private Long noticeId;
    private Long noticeLineId;
    private Long purchaseOrderId;
    private Long purchaseOrderItemId;
    private Long vendorId;
    private Long itemId;
    private String vendorBatch;
    private BigDecimal quantity;
    /** 0 å¾…仓库退货,1 ä»“库已确认退货。 */
    private Integer status;
    private Long confirmedBy;
    private LocalDateTime confirmedAt;
    private String confirmationRemark;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productproduce/MesWmProductProduceLineDO.java
@@ -80,6 +80,8 @@
     * æžšä¸¾ {@link cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum}
     */
    private Integer qualityStatus;
    /** å¤„置不合格产出所使用的 NCR,空表示尚未处理。 */
    private Long ncrDispositionId;
    /**
     * å¤‡æ³¨
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesLineDO.java
@@ -104,6 +104,8 @@
     * æžšä¸¾ {@link MesWmQualityStatusEnum}
     */
    private Integer qualityStatus;
    /** ä¸åˆæ ¼å‡ºè´§è¡Œçš„处置归属,防止多个 NCR é‡å¤å¤„理。 */
    private Long ncrDispositionId;
    /**
     * å¤‡æ³¨
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesLineDO.java
@@ -69,6 +69,8 @@
     * æžšä¸¾ {@link cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum}
     */
    private Integer qualityStatus;
    /** å·²å…¨é‡è½¬äº¤ä¸åˆæ ¼å¤„理的退货行;原退货单不得再次入库该行。 */
    private Long ncrDispositionId;
    /**
     * å¤‡æ³¨
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ncr/MesQcNcrMapper.java
@@ -33,4 +33,10 @@
        return selectOne(MesQcNcrDO::getCode, code);
    }
    /** ä¸²è¡ŒåŒ–同一处理单的状态流转及其下游单据创建。 */
    default MesQcNcrDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<MesQcNcrDO>()
                .eq(MesQcNcrDO::getId, id).last("FOR UPDATE"));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ncr/MesQcPurchaseConcessionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr;
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.ncr.MesQcPurchaseConcessionDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MesQcPurchaseConcessionMapper extends BaseMapperX<MesQcPurchaseConcessionDO> {
    default MesQcPurchaseConcessionDO selectByIqcId(Long id) {
        return selectOne(new LambdaQueryWrapperX<MesQcPurchaseConcessionDO>()
                .eq(MesQcPurchaseConcessionDO::getIqcId, id));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/ncr/MesQcPurchaseReturnMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseReturnDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MesQcPurchaseReturnMapper extends BaseMapperX<MesQcPurchaseReturnDO> {
    default MesQcPurchaseReturnDO selectByNcrId(Long ncrId) {
        return selectOne(new LambdaQueryWrapperX<MesQcPurchaseReturnDO>()
                .eq(MesQcPurchaseReturnDO::getNcrId, ncrId));
    }
    default MesQcPurchaseReturnDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<MesQcPurchaseReturnDO>()
                .eq(MesQcPurchaseReturnDO::getId, id).last("FOR UPDATE"));
    }
    default MesQcPurchaseReturnDO selectConfirmedByIqcId(Long iqcId) {
        return selectOne(new LambdaQueryWrapperX<MesQcPurchaseReturnDO>()
                .eq(MesQcPurchaseReturnDO::getIqcId, iqcId)
                .eq(MesQcPurchaseReturnDO::getStatus, 1).last("LIMIT 1"));
    }
    default MesQcPurchaseReturnDO selectByIqcId(Long iqcId) {
        return selectOne(new LambdaQueryWrapperX<MesQcPurchaseReturnDO>()
                .eq(MesQcPurchaseReturnDO::getIqcId, iqcId));
    }
    default PageResult<MesQcPurchaseReturnDO> selectPage(PageParam page, Integer status) {
        return selectPage(page, new LambdaQueryWrapperX<MesQcPurchaseReturnDO>()
                .eqIfPresent(MesQcPurchaseReturnDO::getStatus, status)
                .orderByDesc(MesQcPurchaseReturnDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/returnsales/MesWmReturnSalesMapper.java
@@ -55,4 +55,9 @@
                .eq(MesWmReturnSalesDO::getCode, code));
    }
    default MesWmReturnSalesDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<MesWmReturnSalesDO>()
                .eq(MesWmReturnSalesDO::getId, id).last("FOR UPDATE"));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/qc/MesNcrDispositionEnum.java
@@ -20,7 +20,8 @@
    REWORK(3, "返工"),
    CONCESSION(4, "让步放行"),
    DOWNGRADE(5, "降级使用"),
    REPAIR(6, "返修");
    REPAIR(6, "返修"),
    REPRODUCTION(7, "重新生产");
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(MesNcrDispositionEnum::getType).toArray(Integer[]::new);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/wm/MesWmMiscIssueTypeEnum.java
@@ -11,7 +11,8 @@
public enum MesWmMiscIssueTypeEnum implements ArrayValuable<Integer> {
    ADJUST(1, "库存调整"),
    SCRAP(2, "报废出库");
    SCRAP(2, "报废出库"),
    REWORK(3, "返工领用");
    private final Integer type;
    private final String name;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/MesQcCompletionHelper.java
@@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.mes.service.qc;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@@ -9,7 +8,6 @@
/** æŒ‰æ•°é‡æ±‡æ€»è´¨æ£€ç»“果,不推断不合格品的后续处置方式。 */
@Component
public class MesQcCompletionHelper {
    @Resource private StorageAttachmentApi storageApi;
    @Resource private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
    public void lockDocument(String type, Long id) {
@@ -29,8 +27,7 @@
        if (total.signum() <= 0 || checked == null || total.compareTo(checked) != 0) {
            throw new ServiceException(400, "合格数量与不合格数量之和须大于零且等于检测数量");
        }
        var attachments = storageApi.listAttachments("mes_qc_" + type + "_attachment", id);
        if (attachments == null || attachments.isEmpty()) throw new ServiceException(400, "请先上传检验附件");
        // æ£€éªŒé™„件改为非必填,完成检验时不再强制校验是否已上传。
        // ä¸ŽçŽ°æœ‰ mes_qc_check_result å­—典一致:1 é€šè¿‡ï¼Œ2 ä¸é€šè¿‡ã€‚
        return unqualified.signum() == 0 ? 1 : 2;
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcArrivalDispositionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseReturnMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseConcessionMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeMapper;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcCheckResultEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmArrivalNoticeStatusEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Objects;
/** ä¾æ®å„行原始质检结论和真实退货确认重新计算到货单状态。 */
@Service
public class MesQcArrivalDispositionService {
    @Resource private MesWmArrivalNoticeMapper arrivalNoticeMapper;
    @Resource private MesWmArrivalNoticeLineMapper lineMapper;
    @Resource private MesQcPurchaseReturnMapper returnMapper;
    @Resource private MesQcPurchaseConcessionMapper concessionMapper;
    @Transactional(rollbackFor = Exception.class)
    public void refresh(Long noticeId) {
        var notice = arrivalNoticeMapper.selectOne(new LambdaQueryWrapperX<MesWmArrivalNoticeDO>()
                .eq(MesWmArrivalNoticeDO::getId, noticeId).last("FOR UPDATE"));
        if (notice == null || !Objects.equals(notice.getStatus(), MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus())) return;
        var lines = lineMapper.selectListByNoticeId(noticeId);
        if (lines.isEmpty()) return;
        boolean hasReceiptQuantity = false;
        for (var line : lines) {
            if (Boolean.TRUE.equals(line.getIqcCheckFlag())) {
                if (line.getIqcId() == null || line.getIqcCheckResult() == null || line.getQualifiedQuantity() == null) return;
                if (!MesQcCheckResultEnum.canInbound(line.getIqcCheckResult())) {
                    var returned = returnMapper.selectConfirmedByIqcId(line.getIqcId());
                    var concession = concessionMapper.selectByIqcId(line.getIqcId());
                    boolean returnDone = returned != null && Objects.equals(returned.getNoticeLineId(), line.getId());
                    boolean concessionDone = concession != null && Objects.equals(concession.getNoticeLineId(), line.getId());
                    if (!returnDone && !concessionDone) return;
                }
            }
            BigDecimal quantity = line.getQualifiedQuantity() != null ? line.getQualifiedQuantity() : line.getArrivalQuantity();
            if (quantity == null || quantity.signum() < 0) return;
            if (quantity.signum() > 0) hasReceiptQuantity = true;
        }
        arrivalNoticeMapper.updateById(new MesWmArrivalNoticeDO().setId(noticeId)
                .setStatus(hasReceiptQuantity ? MesWmArrivalNoticeStatusEnum.PENDING_RECEIPT.getStatus()
                        : MesWmArrivalNoticeStatusEnum.FINISHED.getStatus()));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcConcessionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,156 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.MesWmProductSalesShippingReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.detail.MesWmProductSalesDetailSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesDetailService;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.Objects;
import java.math.BigDecimal;
import java.util.List;
/** å°†è®©æ­¥å†³å®šå›žå†™åŽŸå‡ºåº“è¡Œï¼›å¿…é¡»ç”± NCR å¤„置事务调用。 */
@Component
public class MesQcNcrOqcConcessionService {
    @Resource private MesQcOqcMapper oqcMapper;
    @Resource private MesWmProductSalesMapper salesMapper;
    @Resource private MesWmProductSalesLineMapper lineMapper;
    @Resource private MesWmProductSalesLineService lineService;
    @Resource private MesWmTransactionMapper transactionMapper;
    @Resource private MesWmMaterialStockService stockService;
    @Resource private MesWmProductSalesDetailService detailService;
    @Resource private MesWmProductSalesService salesService;
    public void release(MesQcNcrDO ncr, MesQcNcrDisposeReqVO req) {
        MesQcOqcDO qc = oqcMapper.selectById(ncr.getQcId());
        if (qc == null || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.WM_PRODUCT_SALES)
                || qc.getSourceDocId() == null || qc.getSourceLineId() == null || StrUtil.isBlank(qc.getBatchCode())
                || !Objects.equals(qc.getStatus(), MesQcStatusEnum.FINISHED.getStatus())
                || !Objects.equals(qc.getCheckResult(), 2) || !Objects.equals(qc.getItemId(), ncr.getItemId())
                || qc.getUnqualifiedQuantity() == null || ncr.getUnqualifiedQuantity() == null
                || qc.getUnqualifiedQuantity().signum() <= 0
                || qc.getUnqualifiedQuantity().compareTo(ncr.getUnqualifiedQuantity()) != 0) {
            throw new ServiceException(400, "出货让步需关联已完成的不合格出货检验、原出库行及明确批次,物料和不合格数量须一致");
        }
        MesWmProductSalesDO sales = salesMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .eq(MesWmProductSalesDO::getId, qc.getSourceDocId()).last("FOR UPDATE"));
        MesWmProductSalesLineDO line = lineMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesLineDO>()
                .eq(MesWmProductSalesLineDO::getId, qc.getSourceLineId()).last("FOR UPDATE"));
        if (sales == null || line == null || !Objects.equals(sales.getStatus(), MesWmProductSalesStatusEnum.CONFIRMED.getStatus())
                || !Objects.equals(line.getSalesId(), sales.getId()) || !Objects.equals(line.getOqcId(), qc.getId())
                || !Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())
                || !Objects.equals(line.getItemId(), qc.getItemId()) || line.getQuantity() == null
                || qc.getCheckQuantity() == null || line.getQuantity().signum() <= 0
                || qc.getQualifiedQuantity() == null || qc.getQualifiedQuantity().signum() < 0
                || qc.getQualifiedQuantity().add(qc.getUnqualifiedQuantity()).compareTo(qc.getCheckQuantity()) != 0
                || (StrUtil.isNotBlank(line.getBatchCode()) && !Objects.equals(line.getBatchCode(), qc.getBatchCode()))
                || qc.getCheckQuantity().compareTo(line.getQuantity()) != 0) {
            throw new ServiceException(400, "出货让步要求原出库单处于待检验状态,原行仍绑定本次不合格检验且检测数量覆盖整行");
        }
        MesWmMaterialStockDO stock = req.getMaterialStockId() == null ? null : stockService.getMaterialStock(req.getMaterialStockId());
        if (stock == null || !Objects.equals(stock.getItemId(), qc.getItemId())
                || !Objects.equals(stock.getBatchCode(), qc.getBatchCode())
                || (line.getMaterialStockId() != null && !Objects.equals(line.getMaterialStockId(), stock.getId()))
                || stock.getWarehouseId() == null || stock.getLocationId() == null || stock.getAreaId() == null) {
            throw new ServiceException(400, "请选择原检验批次的实际库存及完整库位;原行指定库存时必须选择该库存");
        }
        var currentDetails = detailService.getProductSalesDetailListByLineId(line.getId());
        if (!currentDetails.isEmpty()) {
            throw new ServiceException(400, "该出库行已有拣货明细,请先核对并清理后重新选择实际库存");
        }
        // å…¶ä»–行也必须完整且可出库;本操作以整张销售出库单为事务边界。
        for (MesWmProductSalesLineDO other : lineService.getProductSalesLineListBySalesId(sales.getId())) {
            if (Objects.equals(other.getId(), line.getId())) continue;
            if (Boolean.TRUE.equals(other.getOqcCheckFlag())
                    && !Objects.equals(other.getQualityStatus(), MesWmQualityStatusEnum.PASS.getStatus())) {
                throw new ServiceException(400, "同一销售出库单还有未通过质检的行,不能整单出库");
            }
            var details = detailService.getProductSalesDetailListByLineId(other.getId());
            BigDecimal total = details.stream().map(MesWmProductSalesDetailDO::getQuantity)
                    .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
            if (details.isEmpty() || other.getQuantity() == null || other.getQuantity().signum() <= 0
                    || total.compareTo(other.getQuantity()) != 0
                    || details.stream().anyMatch(d -> d.getQuantity() == null || d.getQuantity().signum() <= 0
                            || !Objects.equals(d.getSalesId(), sales.getId()) || !Objects.equals(d.getLineId(), other.getId())
                            || !Objects.equals(d.getItemId(), other.getItemId()))) {
                throw new ServiceException(400, "同一销售出库单其他行的拣货明细未备齐,不能整单出库");
            }
        }
        lineService.updateProductSalesLineWhenOqcFinish(line.getId(), qc.getId(), 1);
        MesWmProductSalesDetailSaveReqVO detail = new MesWmProductSalesDetailSaveReqVO();
        detail.setSalesId(sales.getId()); detail.setLineId(line.getId()); detail.setItemId(qc.getItemId());
        detail.setMaterialStockId(stock.getId()); detail.setQuantity(line.getQuantity());
        detail.setBatchId(stock.getBatchId()); detail.setBatchCode(stock.getBatchCode());
        detail.setWarehouseId(stock.getWarehouseId()); detail.setLocationId(stock.getLocationId()); detail.setAreaId(stock.getAreaId());
        detailService.createProductSalesDetail(detail);
        if (!salesService.checkProductSalesQuantity(sales.getId())) {
            throw new ServiceException(400, "销售出库单明细数量不完整,不能整单出库");
        }
        salesService.stockProductSales(sales.getId());
        MesWmProductSalesShippingReqVO shipping = new MesWmProductSalesShippingReqVO();
        shipping.setId(sales.getId());
        salesService.shippingProductSales(shipping);
        salesService.finishProductSales(sales.getId());
    }
    /** å®žé™…出库后才能关闭让步单;不改变原始不合格检验结论。 */
    public void validateBeforeClose(MesQcNcrDO ncr) {
        MesQcOqcDO qc = oqcMapper.selectById(ncr.getQcId());
        if (qc == null || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.WM_PRODUCT_SALES)
                || qc.getSourceDocId() == null || qc.getSourceLineId() == null || StrUtil.isBlank(qc.getBatchCode())
                || !Objects.equals(qc.getItemId(), ncr.getItemId())
                || qc.getUnqualifiedQuantity() == null || ncr.getUnqualifiedQuantity() == null
                || qc.getUnqualifiedQuantity().signum() <= 0
                || qc.getUnqualifiedQuantity().compareTo(ncr.getUnqualifiedQuantity()) != 0) {
            throw new ServiceException(400, "无法核验让步出货的来源检验单及原批次");
        }
        MesWmProductSalesDO sales = salesMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .eq(MesWmProductSalesDO::getId, qc.getSourceDocId()).last("FOR UPDATE"));
        MesWmProductSalesLineDO line = lineMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesLineDO>()
                .eq(MesWmProductSalesLineDO::getId, qc.getSourceLineId()).last("FOR UPDATE"));
        if (sales == null || line == null || !Objects.equals(sales.getStatus(), MesWmProductSalesStatusEnum.FINISHED.getStatus())
                || !Objects.equals(line.getSalesId(), sales.getId()) || !Objects.equals(line.getOqcId(), qc.getId())
                || !Objects.equals(line.getItemId(), qc.getItemId()) || line.getQuantity() == null
                || qc.getCheckQuantity() == null || line.getQuantity().compareTo(qc.getCheckQuantity()) != 0) {
            throw new ServiceException(400, "原销售出库单尚未完成,或出库行与检验来源不一致,不能关闭");
        }
        List<MesWmTransactionDO> flows = transactionMapper.selectList(new LambdaQueryWrapperX<MesWmTransactionDO>()
                .eq(MesWmTransactionDO::getBizType, MesBizTypeConstants.WM_PRODUCT_SALES)
                .eq(MesWmTransactionDO::getBizId, sales.getId()).eq(MesWmTransactionDO::getBizLineId, line.getId())
                .eq(MesWmTransactionDO::getType, MesWmTransactionTypeEnum.OUT.getType()));
        BigDecimal out = BigDecimal.ZERO;
        for (MesWmTransactionDO flow : flows) {
            if (!Objects.equals(flow.getItemId(), qc.getItemId()) || !Objects.equals(flow.getBatchCode(), qc.getBatchCode())
                    || flow.getQuantity() == null || flow.getQuantity().signum() >= 0) {
                throw new ServiceException(400, "出库流水的物料、批次或数量与让步检验不一致");
            }
            out = out.subtract(flow.getQuantity());
        }
        if (out.compareTo(line.getQuantity()) != 0) {
            throw new ServiceException(400, "原销售出库流水数量不完整,不能关闭让步单");
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReproductionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,176 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.MesProWorkOrderSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.MesProTaskAutoGenerateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.process.MesProWorkOrderProcessSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdItemMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderBomMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.batch.MesWmBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderSourceTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderTypeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Objects;
/** å‡ºè´§æˆ–过程不合格重新生产,复制来源工单的工序及 BOM å¿«ç…§ã€‚ */
@Service
public class MesQcNcrOqcReproductionService {
    @Resource private MesQcOqcMapper oqcMapper;
    @Resource private MesWmProductSalesLineMapper salesLineMapper;
    @Resource private MesWmProductSalesMapper salesMapper;
    @Resource private MesWmProductProduceLineMapper produceLineMapper;
    @Resource private MesQcNcrScrapService sourceService;
    @Resource private MesWmBatchMapper batchMapper;
    @Resource private MesProWorkOrderMapper orderMapper;
    @Resource private MesProWorkOrderBomMapper bomMapper;
    @Resource private MesMdItemMapper itemMapper;
    @Resource private MesProWorkOrderService orderService;
    @Resource private MesProWorkOrderProcessService processService;
    @Resource private MesProTaskService taskService;
    @Resource private MesMdAutoCodeRecordService autoCodeService;
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO reproduce(MesQcNcrDO ncr, Integer disposition) {
        if (!Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType()) || ncr.getQcId() == null
                || ncr.getUnqualifiedQuantity() == null || ncr.getUnqualifiedQuantity().signum() <= 0)
            throw error("重新生产需要关联出货质检及有效不合格数量");
        var qc = oqcMapper.selectById(ncr.getQcId());
        if (qc == null || !Objects.equals(qc.getStatus(), MesQcStatusEnum.FINISHED.getStatus())
                || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.WM_PRODUCT_SALES)
                || qc.getSourceDocId() == null || qc.getSourceLineId() == null
                || !Objects.equals(qc.getItemId(), ncr.getItemId()) || !same(qc.getUnqualifiedQuantity(), ncr.getUnqualifiedQuantity()))
            throw error("出货检验来源、物料或不合格数量不一致,无法重新生产");
        var line = salesLineMapper.selectById(qc.getSourceLineId());
        var sales = salesMapper.selectById(qc.getSourceDocId());
        if (line == null || sales == null || !Objects.equals(line.getSalesId(), sales.getId())
                || !Objects.equals(line.getOqcId(), qc.getId()) || !Objects.equals(line.getItemId(), ncr.getItemId())
                || !Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())
                || !same(line.getQuantity(), qc.getOutQuantity()) || ncr.getUnqualifiedQuantity().compareTo(line.getQuantity()) > 0)
            throw error("无法唯一核对出货检验对应的不合格出货行和数量");
        if (sales.getClientId() == null || !Objects.equals(sales.getClientId(), qc.getClientId())
                || line.getBatchId() == null || StrUtil.isBlank(line.getBatchCode())
                || !Objects.equals(line.getBatchCode(), qc.getBatchCode()))
            throw error("出货检验缺少一致的客户或批次,无法确定原生产订单");
        var batch = batchMapper.selectById(line.getBatchId());
        if (batch == null || !Objects.equals(batch.getCode(), line.getBatchCode())
                || !Objects.equals(batch.getItemId(), ncr.getItemId()) || !Objects.equals(batch.getClientId(), sales.getClientId())
                || batch.getWorkOrderId() == null || (StrUtil.isNotBlank(qc.getReelNo()) && !Objects.equals(qc.getReelNo(), batch.getReelNo())))
            throw error("出货批次未绑定一致的客户、物料、盘号和原生产订单");
        var original = orderMapper.selectById(batch.getWorkOrderId());
        if (original == null || !Objects.equals(original.getClientId(), sales.getClientId()) || original.getProductId() == null)
            throw error("批次绑定的原生产订单不存在或客户不一致");
        return createFromOriginal(ncr, disposition, original, sales.getClientId(), batch.getCode(), "重新生产-");
    }
    /** è°ƒç”¨å‰é¡»åœ¨åŒä¸€äº‹åŠ¡å†…é€šè¿‡ claimIpqcSource é”å®šå¹¶æ ¸é”€è¯¥ä¸åˆæ ¼äº§å‡ºã€‚ */
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO reproduceIpqc(MesQcNcrDO ncr, Integer disposition,
                                               MesQcNcrScrapService.IpqcSource source) {
        var verified = sourceService.validateIpqcSource(ncr);
        if (source == null || !Objects.equals(source, verified)) throw error("过程检验来源产出已变化,请刷新后重试");
        var line = produceLineMapper.selectById(source.sourceLineId());
        if (line == null || ncr.getId() == null || !Objects.equals(line.getNcrDispositionId(), ncr.getId()))
            throw error("过程不合格产出尚未由该处理单核销");
        var original = orderMapper.selectById(source.orderId());
        if (original == null || original.getProductId() == null) throw error("过程检验来源生产订单不存在");
        return createFromOriginal(ncr, disposition, original, original.getClientId(), source.batchCode(), "重新生产-");
    }
    /**
     * å‡ºè´§ä¸åˆæ ¼è¿”å·¥/返修:复用原工单的工艺路线与 BOM å…‹éš†ä¸€å¼ è¿”工工单。
     *
     * <p>调用方须已完成来源认领、原成品库存出库及原销售出库单取消。</p>
     */
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO createReworkFromOriginal(MesQcNcrDO ncr, Integer disposition,
                                                          MesProWorkOrderDO original, Long clientId,
                                                          String batchCode, String namePrefix) {
        return createFromOriginal(ncr, disposition, original, clientId, batchCode, namePrefix);
    }
    private MesQcNcrDisposeRespVO createFromOriginal(MesQcNcrDO ncr, Integer disposition,
                                                     MesProWorkOrderDO original, Long clientId, String batchCode,
                                                     String namePrefix) {
        var product = itemMapper.selectById(original.getProductId());
        if (product == null || !Objects.equals(product.getMdmItemId(), ncr.getItemId()))
            throw error("原生产订单产品与不合格物料映射不一致");
        var processes = processService.getWorkOrderProcessListByWorkOrderId(original.getId());
        if (processes == null || processes.isEmpty()) throw error("原生产订单没有工序快照,无法自动下达重新生产");
        if (original.getRouteId() == null) throw error("原生产订单未记录工艺路线,无法自动生成生产任务");
        var sourceBoms = bomMapper.selectListByWorkOrderId(original.getId());
        if (sourceBoms == null) throw error("无法读取原工单投料 BOM");
        if (sourceBoms.stream().anyMatch(b -> b.getItemId() == null || b.getQuantity() == null || b.getQuantity().signum() < 0
                || (b.getProcessId() == null && (original.getQuantity() == null || original.getQuantity().signum() <= 0))))
            throw error("原工单 BOM æˆ–订单数量无效,无法复制投料");
        if (sourceBoms.stream().anyMatch(b -> b.getProcessId() != null && processes.stream()
                .noneMatch(p -> Objects.equals(p.getProcessId(), b.getProcessId()))))
            throw error("原工单 BOM å…³è”了不存在的工序,无法自动复制");
        String code = autoCodeService.generateAutoCode(MesMdAutoCodeRuleCodeEnum.PRO_WORK_ORDER_CODE.getCode());
        var request = new MesProWorkOrderSaveReqVO();
        request.setCode(code); request.setName(namePrefix + ncr.getCode()); request.setType(MesProWorkOrderTypeEnum.SELF.getType());
        request.setOrderSourceType(MesProWorkOrderSourceTypeEnum.STORE.getType()); request.setOrderSourceCode(ncr.getCode());
        request.setProductId(original.getProductId()); request.setReelNo(original.getReelNo());
        request.setClientId(clientId); request.setQuantity(ncr.getUnqualifiedQuantity());
        request.setRequestDate(LocalDateTime.now()); request.setFeedbackApprove(original.getFeedbackApprove());
        request.setProcessDefinitionKey(original.getProcessDefinitionKey()); request.setFeedbackRole(original.getFeedbackRole());
        request.setRemark("由不合格单[" + ncr.getCode() + "]" + StrUtil.removeSuffix(namePrefix, "-")
                + ",原工单[" + original.getCode() + "],原批次[" + StrUtil.blankToDefault(batchCode, "无") + "]");
        Long id = orderService.createWorkOrder(request);
        orderMapper.updateById(new MesProWorkOrderDO().setId(id).setRouteId(original.getRouteId()).setAutoFinishFlag(original.getAutoFinishFlag()));
        for (var process : processes) {
            var copy = BeanUtils.toBean(process, MesProWorkOrderProcessSaveReqVO.class);
            copy.setId(null); copy.setWorkOrderId(id);
            if (StrUtil.isNotBlank(process.getOutputItemId())) {
                try { copy.setOutputItemIds(Arrays.stream(process.getOutputItemId().split(",")).map(String::trim).map(Long::valueOf).toList()); }
                catch (NumberFormatException ex) { throw error("原工单工序产出物料编号格式错误,无法复制"); }
            }
            processService.createWorkOrderProcess(copy);
        }
        // å·¥åº BOM æ˜¯å•位用量,普通工单 BOM æ˜¯è®¢å•总用量;均使用原工单快照。
        bomMapper.deleteByWorkOrderId(id);
        for (var bom : sourceBoms) {
            BigDecimal amount = bom.getProcessId() != null ? bom.getQuantity()
                    : bom.getQuantity().multiply(ncr.getUnqualifiedQuantity()).divide(original.getQuantity(), 8, RoundingMode.CEILING);
            bomMapper.insert(new MesProWorkOrderBomDO().setWorkOrderId(id).setProcessId(bom.getProcessId())
                    .setItemId(bom.getItemId()).setQuantity(amount).setRemark(bom.getRemark()));
        }
        orderService.confirmWorkOrder(id);
        var taskReq = new MesProTaskAutoGenerateReqVO();
        taskReq.setWorkOrderId(id); taskReq.setRouteId(original.getRouteId());
        var taskIds = taskService.autoGenerateTasks(taskReq);
        if (taskIds == null || taskIds.isEmpty()) throw error("重新生产工单未生成生产任务,无法下达");
        var result = new MesQcNcrDisposeRespVO();
        result.setDisposition(disposition); result.setDocType("WORK_ORDER"); result.setDocId(id);
        result.setDocCode(code); result.setDocName(request.getName()); result.setMessage("已确认重新生产订单[" + code + "],生成 " + taskIds.size() + " æ¡ç”Ÿäº§ä»»åŠ¡è‰ç¨¿ï¼Œå¾…æŽ’ç¨‹");
        return result;
    }
    private static boolean same(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; }
    private static ServiceException error(String message) { return new ServiceException(400, message); }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReworkService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,398 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.miscissue.vo.MesWmMiscIssueSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.miscissue.vo.line.MesWmMiscIssueLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.miscissue.MesWmMiscIssueDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
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.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
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.dal.mysql.wm.miscissue.MesWmMiscIssueMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.md.autocode.MesMdAutoCodeRuleCodeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderSourceTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrDispositionEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmMiscIssueStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmMiscIssueTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import cn.iocoder.yudao.module.mes.service.wm.miscissue.MesWmMiscIssueLineService;
import cn.iocoder.yudao.module.mes.service.wm.miscissue.MesWmMiscIssueService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesService;
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 jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
 * å‡ºè´§æ£€éªŒï¼ˆOQC)不合格返工/返修闭环。
 *
 * <p>处置时扣减原成品库存并生成返工领用出库单,自动取消原销售出库单,
 * å†å¤ç”¨åŽŸç”Ÿäº§å·¥å•å…‹éš†ä¸€å¼ è¿”å·¥å·¥å•ï¼›å¤æ£€é€šè¿‡åŽç”± NCR å…³é—­æµç¨‹æ ¸éªŒã€‚</p>
 */
@Service
public class MesQcNcrOqcReworkService {
    /** è¿”工领用出库单的来源单据类型 */
    private static final String MISC_ISSUE_SOURCE_TYPE_NCR = "QC_NCR";
    @Resource private MesWmBatchMapper batchMapper;
    @Resource private MesProWorkOrderMapper orderMapper;
    @Resource private MesQcOqcMapper oqcMapper;
    @Resource private MesWmMaterialStockMapper stockMapper;
    @Resource private MesWmMiscIssueMapper miscIssueMapper;
    @Resource private MesWmTransactionMapper transactionMapper;
    @Resource private MesWmProductSalesMapper salesMapper;
    @Resource private MesWmMaterialStockService stockService;
    @Resource private MesWmProductSalesService salesService;
    @Resource private MesWmMiscIssueService miscIssueService;
    @Resource private MesWmMiscIssueLineService miscIssueLineService;
    @Resource private MesMdAutoCodeRecordService autoCodeRecordService;
    @Resource private MesMdItemService itemService;
    @Resource private MesQcNcrOqcReproductionService reproductionService;
    @Resource private MesWmWarehouseService warehouseService;
    @Resource private MesWmWarehouseLocationService warehouseLocationService;
    @Resource private MesWmWarehouseAreaService warehouseAreaService;
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO dispose(MesQcNcrDO ncr, MesQcNcrOqcSourceClaim.OqcClaimedSource source,
                                         MesQcNcrDisposeReqVO req) {
        validateSource(ncr, source);
        MesWmBatchDO batch = validateBatch(ncr, source);
        MesProWorkOrderDO original = validateOriginalWorkOrder(batch, source);
        MesWmMaterialStockDO stock = resolveAndLockStock(ncr, source, req);
        // åŽŸé”€å”®å‡ºåº“å•æ•´å•å–æ¶ˆï¼Œé‡Šæ”¾å ç”¨ï¼›å¿…é¡»åœ¨æ‰£å‡åº“å­˜å‰å®Œæˆ
        salesService.cancelProductSales(source.sales().getId());
        String issueCode = createReworkIssue(ncr, stock, source.sales().getCode());
        String namePrefix = Objects.equals(req.getDisposition(), MesNcrDispositionEnum.REWORK.getType())
                ? "返工-" : "返修-";
        MesQcNcrDisposeRespVO result = reproductionService.createReworkFromOriginal(
                ncr, req.getDisposition(), original, source.sales().getClientId(), batch.getCode(), namePrefix);
        result.setMessage(String.format(
                "已生成返工领用出库单[%s]并完成出库,原销售出库单[%s]已取消;返工工单[%s]已下达。"
                        + "请完成返工后做过程检验复检,复检合格后关闭本单",
                issueCode, source.sales().getCode(), result.getDocCode()));
        return result;
    }
    /**
     * å…³é—­è¿”å·¥/返修 NCR å‰æ ¸éªŒï¼šè¿”工工单已完成、原销售出库单已取消、返工领用出库已执行。
     */
    public void validateBeforeClose(MesQcNcrDO ncr) {
        List<MesProWorkOrderDO> orders = orderMapper.selectList(new LambdaQueryWrapperX<MesProWorkOrderDO>()
                .eq(MesProWorkOrderDO::getOrderSourceType, MesProWorkOrderSourceTypeEnum.STORE.getType())
                .eq(MesProWorkOrderDO::getOrderSourceCode, ncr.getCode()));
        if (orders.size() != 1) {
            throw new ServiceException(400, String.format(
                    "无法唯一确定不合格单[%s]对应的返工/返修生产订单(当前匹配[%s]张),请先核对来源编码",
                    ncr.getCode(), orders.size()));
        }
        MesProWorkOrderDO order = orders.get(0);
        if (!Objects.equals(order.getStatus(), MesProWorkOrderStatusEnum.FINISHED.getStatus())
                || order.getFinishDate() == null) {
            throw new ServiceException(400, String.format(
                    "返工/返修生产订单[%s]尚未完成或缺少完成时间,请完成返工并报工后再关闭",
                    order.getCode()));
        }
        MesWmProductSalesDO sales = resolveOriginalSales(ncr);
        if (sales == null) {
            throw new ServiceException(400, "未找到不合格单关联的原销售出库单,无法核验返工闭环");
        }
        if (!Objects.equals(sales.getStatus(), MesWmProductSalesStatusEnum.CANCELED.getStatus())) {
            throw new ServiceException(400, String.format(
                    "原销售出库单[%s]当前状态为[%s],返工处置应已自动取消该出库单;"
                            + "请核对是否被重新提交或人工修改", sales.getCode(), salesStatusName(sales.getStatus())));
        }
        List<MesWmMiscIssueDO> issues = miscIssueMapper.selectList(new LambdaQueryWrapperX<MesWmMiscIssueDO>()
                .eq(MesWmMiscIssueDO::getSourceDocType, MISC_ISSUE_SOURCE_TYPE_NCR)
                .eq(MesWmMiscIssueDO::getSourceDocId, ncr.getId())
                .eq(MesWmMiscIssueDO::getType, MesWmMiscIssueTypeEnum.REWORK.getType()));
        if (issues.size() != 1) {
            throw new ServiceException(400, String.format(
                    "不合格单[%s]关联的返工领用出库单数量为[%s]张,无法核验返工领用库存",
                    ncr.getCode(), issues.size()));
        }
        MesWmMiscIssueDO issue = issues.get(0);
        if (!Objects.equals(issue.getStatus(), MesWmMiscIssueStatusEnum.FINISHED.getStatus())) {
            throw new ServiceException(400, String.format(
                    "返工领用出库单[%s]尚未完成出库,请先在杂项出库中完成后再关闭", issue.getCode()));
        }
        List<MesWmTransactionDO> flows = transactionMapper.selectList(new LambdaQueryWrapperX<MesWmTransactionDO>()
                .eq(MesWmTransactionDO::getBizType, MesBizTypeConstants.WM_MISC_ISSUE)
                .eq(MesWmTransactionDO::getBizId, issue.getId())
                .eq(MesWmTransactionDO::getType, MesWmTransactionTypeEnum.OUT.getType()));
        BigDecimal out = BigDecimal.ZERO;
        for (MesWmTransactionDO flow : flows) {
            if (!Objects.equals(flow.getItemId(), ncr.getItemId())
                    || flow.getQuantity() == null || flow.getQuantity().signum() >= 0) {
                throw new ServiceException(400, String.format(
                        "返工领用出库单[%s]存在物料或数量不一致的出库流水,请核对后重试", issue.getCode()));
            }
            out = out.subtract(flow.getQuantity());
        }
        if (out.compareTo(ncr.getUnqualifiedQuantity()) != 0) {
            throw new ServiceException(400, String.format(
                    "返工领用出库数量[%s]与不合格数量[%s]不一致,请核对返工领用出库单[%s]",
                    plain(out), plain(ncr.getUnqualifiedQuantity()), issue.getCode()));
        }
    }
    // ==================== æ ¡éªŒä¸Žå®šä½ ====================
    private void validateSource(MesQcNcrDO ncr, MesQcNcrOqcSourceClaim.OqcClaimedSource source) {
        if (source == null || source.qc() == null || source.sales() == null || source.line() == null) {
            throw new ServiceException(400, "出货不合格返工缺少已认领的来源检验单、销售出库单或出库行");
        }
        MesWmProductSalesLineDO line = source.line();
        if (ncr.getUnqualifiedQuantity() == null || ncr.getUnqualifiedQuantity().signum() <= 0) {
            throw new ServiceException(400, "不合格数量必须大于 0 æ‰èƒ½è¿”å·¥/返修");
        }
        if (!Objects.equals(source.sales().getStatus(), MesWmProductSalesStatusEnum.CONFIRMED.getStatus())
                || !Objects.equals(line.getSalesId(), source.sales().getId())
                || !Objects.equals(line.getOqcId(), source.qc().getId())
                || !Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())
                || !Objects.equals(line.getItemId(), ncr.getItemId())
                || line.getBatchId() == null || StrUtil.isBlank(line.getBatchCode())
                || line.getQuantity() == null
                || ncr.getUnqualifiedQuantity().compareTo(line.getQuantity()) > 0) {
            throw new ServiceException(400, "出货检验来源、销售出库行、物料、批次或不合格数量不一致,无法返工/返修");
        }
    }
    private MesWmBatchDO validateBatch(MesQcNcrDO ncr, MesQcNcrOqcSourceClaim.OqcClaimedSource source) {
        MesWmProductSalesLineDO line = source.line();
        MesWmBatchDO batch = batchMapper.selectById(line.getBatchId());
        if (batch == null || !Objects.equals(batch.getCode(), line.getBatchCode())
                || !Objects.equals(batch.getItemId(), line.getItemId())
                || !Objects.equals(batch.getClientId(), source.sales().getClientId())
                || batch.getWorkOrderId() == null) {
            throw new ServiceException(400, String.format(
                    "出货批次[%s]未绑定一致的物料[%s]、客户或原生产订单,无法返工/返修",
                    StrUtil.blankToDefault(line.getBatchCode(), "未填写"), itemLabel(ncr.getItemId())));
        }
        return batch;
    }
    private MesProWorkOrderDO validateOriginalWorkOrder(MesWmBatchDO batch,
                                                        MesQcNcrOqcSourceClaim.OqcClaimedSource source) {
        MesProWorkOrderDO original = orderMapper.selectById(batch.getWorkOrderId());
        if (original == null || original.getProductId() == null
                || !Objects.equals(original.getClientId(), source.sales().getClientId())) {
            throw new ServiceException(400, String.format(
                    "出货批次[%s]关联的原生产订单不存在、缺少产品或不属于客户[ID=%s],无法返工/返修",
                    batch.getCode(), source.sales().getClientId()));
        }
        return original;
    }
    /**
     * å®šä½å¹¶é”å®šåŽŸæˆå“åº“å­˜ï¼šä¼˜å…ˆä½¿ç”¨å‰ç«¯é€‰å®šçš„åº“å­˜è®°å½•ï¼›æœªæŒ‡å®šæ—¶è¦æ±‚åŒæ‰¹æ¬¡ä»…ä¸€æ¡å¯ç”¨åº“å­˜ã€‚
     */
    private MesWmMaterialStockDO resolveAndLockStock(MesQcNcrDO ncr, MesQcNcrOqcSourceClaim.OqcClaimedSource source,
                                                     MesQcNcrDisposeReqVO req) {
        MesWmProductSalesLineDO line = source.line();
        BigDecimal need = ncr.getUnqualifiedQuantity();
        MesWmMaterialStockDO stock;
        if (req.getMaterialStockId() != null) {
            stock = stockService.getMaterialStock(req.getMaterialStockId());
            if (stock == null) {
                throw new ServiceException(400, String.format(
                        "所选返工库存记录[ID=%s]不存在,请重新选择该批次成品库存", req.getMaterialStockId()));
            }
            if (!Objects.equals(stock.getItemId(), line.getItemId())
                    || !Objects.equals(stock.getBatchId(), line.getBatchId())) {
                throw new ServiceException(400, String.format(
                        "所选库存与不合格出货批次不一致:所选库存物料[%s]、批次[%s],"
                                + "出货行物料[%s]、批次[%s]。请重新选择该批次成品库存",
                        itemLabel(stock.getItemId()), batchLabel(stock.getBatchId(), stock.getBatchCode()),
                        itemLabel(line.getItemId()), batchLabel(line.getBatchId(), line.getBatchCode())));
            }
            if (stock.getWarehouseId() == null || stock.getLocationId() == null || stock.getAreaId() == null) {
                throw new ServiceException(400, String.format(
                        "所选库存[ID=%s]缺少仓库/库区/库位信息,无法出库;请重新选择完整的库存记录",
                        stock.getId()));
            }
        } else {
            List<MesWmMaterialStockDO> candidates = stockMapper.selectList(new LambdaQueryWrapperX<MesWmMaterialStockDO>()
                    .eq(MesWmMaterialStockDO::getItemId, line.getItemId())
                    .eq(MesWmMaterialStockDO::getBatchId, line.getBatchId())
                    .gt(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO));
            if (candidates.isEmpty()) {
                throw new ServiceException(400, String.format(
                        "未找到物料[%s]、批次[%s]的成品库存,无法返工领用;请先确认成品已入库或刷新后重试",
                        itemLabel(line.getItemId()), batchLabel(line.getBatchId(), line.getBatchCode())));
            }
            List<MesWmMaterialStockDO> available = candidates.stream()
                    .filter(item -> available(item).compareTo(need) >= 0)
                    .toList();
            if (available.size() != 1) {
                String detail = candidates.stream()
                        .map(item -> String.format("%s(可用量[%s])", stockLabel(item), plain(available(item))))
                        .collect(Collectors.joining(";"));
                throw new ServiceException(400, String.format(
                        "批次[%s]库存无法自动确定返工领用记录:%s。"
                                + "请在处置弹窗的[返工库存]中选择具体库存记录后重试",
                        batchLabel(line.getBatchId(), line.getBatchCode()), detail));
            }
            stock = available.get(0);
        }
        // é‡æ–°åŠ é”åŽè®¡ç®—å¯ç”¨é‡ï¼Œé¿å…å¹¶å‘å‡ºåº“å¯¼è‡´è¶…é¢†
        MesWmMaterialStockDO locked = stockMapper.lockById(stock.getId());
        if (locked == null) {
            throw new ServiceException(400, String.format(
                    "返工库存记录[ID=%s]已不存在,请刷新后重新选择", stock.getId()));
        }
        BigDecimal available = available(locked);
        if (available.compareTo(need) < 0) {
            throw new ServiceException(400, String.format(
                    "批次[%s]库存可用量[%s]不足,本次返工需领用[%s];"
                            + "请先处理库存占用/冻结,或核对成品入库数量",
                    batchLabel(locked.getBatchId(), locked.getBatchCode()), plain(available), plain(need)));
        }
        return locked;
    }
    // ==================== å•据生成 ====================
    private String createReworkIssue(MesQcNcrDO ncr, MesWmMaterialStockDO stock, String salesCode) {
        String code = autoCodeRecordService.generateAutoCode(
                MesMdAutoCodeRuleCodeEnum.WM_MISC_ISSUE_CODE.getCode());
        MesWmMiscIssueSaveReqVO issueReqVO = new MesWmMiscIssueSaveReqVO();
        issueReqVO.setCode(code);
        issueReqVO.setName("返工领用出库单-" + ncr.getCode());
        issueReqVO.setType(MesWmMiscIssueTypeEnum.REWORK.getType());
        issueReqVO.setSourceDocType(MISC_ISSUE_SOURCE_TYPE_NCR);
        issueReqVO.setSourceDocId(ncr.getId());
        issueReqVO.setSourceDocCode(ncr.getCode());
        issueReqVO.setIssueDate(LocalDateTime.now());
        issueReqVO.setRemark(String.format("由不合格品处理单[%s]返工领用自动生成;原销售出库单[%s]已取消",
                ncr.getCode(), salesCode));
        Long issueId = miscIssueService.createMiscIssue(issueReqVO);
        MesWmMiscIssueLineSaveReqVO lineReqVO = new MesWmMiscIssueLineSaveReqVO();
        lineReqVO.setIssueId(issueId);
        lineReqVO.setItemId(ncr.getItemId());
        lineReqVO.setQuantity(ncr.getUnqualifiedQuantity());
        lineReqVO.setMaterialStockId(stock.getId());
        lineReqVO.setBatchId(stock.getBatchId());
        lineReqVO.setBatchCode(stock.getBatchCode());
        lineReqVO.setWarehouseId(stock.getWarehouseId());
        lineReqVO.setLocationId(stock.getLocationId());
        lineReqVO.setAreaId(stock.getAreaId());
        lineReqVO.setRemark(String.format("来源不合格品处理单[%s]返工领用", ncr.getCode()));
        miscIssueLineService.createMiscIssueLine(lineReqVO);
        miscIssueService.submitMiscIssue(issueId);
        miscIssueService.finishMiscIssue(issueId);
        return code;
    }
    /**
     * é€šè¿‡æ¥æºæ£€éªŒå•定位原销售出库单(仅用于关闭核验)。
     */
    private MesWmProductSalesDO resolveOriginalSales(MesQcNcrDO ncr) {
        if (ncr.getQcId() == null) {
            return null;
        }
        MesQcOqcDO qc = oqcMapper.selectById(ncr.getQcId());
        return qc == null || qc.getSourceDocId() == null ? null : salesMapper.selectById(qc.getSourceDocId());
    }
    // ==================== å±•示辅助 ====================
    private static BigDecimal available(MesWmMaterialStockDO stock) {
        BigDecimal quantity = stock.getQuantity() == null ? BigDecimal.ZERO : stock.getQuantity();
        BigDecimal frozen = stock.getFrozenQuantity() == null ? BigDecimal.ZERO : stock.getFrozenQuantity();
        BigDecimal reserved = stock.getReservedQuantity() == null ? BigDecimal.ZERO : stock.getReservedQuantity();
        return quantity.subtract(frozen).subtract(reserved);
    }
    private String itemLabel(Long itemId) {
        MesMdItemDO item = itemId == null ? null : itemService.getItem(itemId);
        if (item == null) {
            return "ID=" + itemId;
        }
        String label = (StrUtil.blankToDefault(item.getCode(), "") + " "
                + StrUtil.blankToDefault(item.getName(), "")).trim();
        return label.isEmpty() ? "ID=" + itemId : label;
    }
    private String batchLabel(Long batchId, String batchCode) {
        if (StrUtil.isNotBlank(batchCode)) {
            return batchCode;
        }
        return batchId == null ? "未填写" : "ID=" + batchId;
    }
    private String stockLabel(MesWmMaterialStockDO stock) {
        return String.format("%s/%s/%s", warehouseName(stock.getWarehouseId()),
                locationName(stock.getLocationId()), areaName(stock.getAreaId()));
    }
    private String warehouseName(Long id) {
        MesWmWarehouseDO warehouse = id == null ? null : warehouseService.getWarehouse(id);
        return warehouse != null && StrUtil.isNotBlank(warehouse.getName()) ? warehouse.getName() : "ID=" + id;
    }
    private String locationName(Long id) {
        MesWmWarehouseLocationDO location = id == null ? null : warehouseLocationService.getWarehouseLocation(id);
        return location != null && StrUtil.isNotBlank(location.getName()) ? location.getName() : "ID=" + id;
    }
    private String areaName(Long id) {
        MesWmWarehouseAreaDO area = id == null ? null : warehouseAreaService.getWarehouseArea(id);
        return area != null && StrUtil.isNotBlank(area.getName()) ? area.getName() : "ID=" + id;
    }
    private static String salesStatusName(Integer status) {
        for (MesWmProductSalesStatusEnum value : MesWmProductSalesStatusEnum.values()) {
            if (Objects.equals(value.getStatus(), status)) {
                return value.getName();
            }
        }
        return "ID=" + status;
    }
    private static String plain(BigDecimal value) {
        return value == null ? "0" : value.stripTrailingZeros().toPlainString();
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcSourceClaim.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.Objects;
/** è°ƒç”¨æ–¹é¡»æŒæœ‰ NCR è¡Œé”å¹¶åœ¨åŒä¸€äº‹åŠ¡å†…å®ŒæˆåŽç»­å¤„ç½®ã€‚ */
@Component
public class MesQcNcrOqcSourceClaim {
    @Resource private MesQcOqcMapper oqcMapper;
    @Resource private MesWmProductSalesMapper salesMapper;
    @Resource private MesWmProductSalesLineMapper lineMapper;
    public OqcClaimedSource claim(MesQcNcrDO ncr) {
        var qc = oqcMapper.selectById(ncr.getQcId());
        if (qc == null || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.WM_PRODUCT_SALES)
                || qc.getSourceDocId() == null || qc.getSourceLineId() == null
                || !Objects.equals(qc.getItemId(), ncr.getItemId()) || qc.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity() == null || qc.getUnqualifiedQuantity().compareTo(ncr.getUnqualifiedQuantity()) != 0) {
            throw new ServiceException(400, "出货不合格单来源或数量不一致");
        }
        var sales = salesMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .eq(MesWmProductSalesDO::getId, qc.getSourceDocId()).last("FOR UPDATE"));
        var line = lineMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesLineDO>()
                .eq(MesWmProductSalesLineDO::getId, qc.getSourceLineId()).last("FOR UPDATE"));
        if (sales == null || line == null || !Objects.equals(sales.getStatus(), MesWmProductSalesStatusEnum.CONFIRMED.getStatus())
                || !Objects.equals(line.getSalesId(), sales.getId()) || !Objects.equals(line.getOqcId(), qc.getId())
                || !Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())
                || line.getNcrDispositionId() != null) {
            throw new ServiceException(400, "原出货行已处置或不处于等待检验处置状态");
        }
        lineMapper.updateById(new MesWmProductSalesLineDO().setId(line.getId()).setNcrDispositionId(ncr.getId()));
        return new OqcClaimedSource(qc, sales, line);
    }
    /** å·²é”å®šçš„出货不合格来源(检验单、销售出库单、销售出库行)。 */
    public record OqcClaimedSource(MesQcOqcDO qc, MesWmProductSalesDO sales, MesWmProductSalesLineDO line) {
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrPurchaseSourceValidator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.iqc.MesQcIqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmArrivalNoticeStatusEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Objects;
/** é‡‡è´­å¤„置前锁定并核对检验单和到货行;调用者须处于事务中。 */
@Component
public class MesQcNcrPurchaseSourceValidator {
    @Resource private MesQcIqcMapper iqcMapper;
    @Resource private MesWmArrivalNoticeMapper arrivalNoticeMapper;
    @Resource private MesWmArrivalNoticeLineMapper lineMapper;
    public record Source(MesQcIqcDO iqc, MesWmArrivalNoticeDO notice, MesWmArrivalNoticeLineDO line) {}
    public Source validate(MesQcNcrDO ncr) {
        if (!Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType()) || ncr.getQcId() == null)
            throw new ServiceException(400, "采购不合格处置必须关联来料检验单");
        var iqc = iqcMapper.selectOne(new LambdaQueryWrapperX<MesQcIqcDO>()
                .eq(MesQcIqcDO::getId, ncr.getQcId()).last("FOR UPDATE"));
        if (iqc == null || !Objects.equals(iqc.getStatus(), MesQcStatusEnum.FINISHED.getStatus())
                || !Objects.equals(iqc.getSourceDocType(), MesBizTypeConstants.WM_ARRIVAL_NOTICE)
                || iqc.getSourceDocId() == null || iqc.getSourceLineId() == null)
            throw new ServiceException(400, "采购处置仅支持已完成且关联到货通知单行的来料检验");
        var notice = arrivalNoticeMapper.selectOne(new LambdaQueryWrapperX<MesWmArrivalNoticeDO>()
                .eq(MesWmArrivalNoticeDO::getId, iqc.getSourceDocId()).last("FOR UPDATE"));
        var line = lineMapper.selectOne(new LambdaQueryWrapperX<MesWmArrivalNoticeLineDO>()
                .eq(MesWmArrivalNoticeLineDO::getId, iqc.getSourceLineId()).last("FOR UPDATE"));
        if (notice == null || line == null || !Objects.equals(line.getNoticeId(), notice.getId())
                || !Objects.equals(line.getIqcId(), iqc.getId())
                || !Objects.equals(notice.getStatus(), MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus())
                || !Objects.equals(line.getItemId(), iqc.getItemId())
                || !Objects.equals(ncr.getItemId(), iqc.getItemId())
                || !samePositive(ncr.getUnqualifiedQuantity(), iqc.getUnqualifiedQuantity())
                || !samePositive(iqc.getCheckQuantity(), line.getArrivalQuantity()))
            throw new ServiceException(400, "到货单、质检单及不合格单来源或数量不一致,无法自动处置");
        return new Source(iqc, notice, line);
    }
    private boolean samePositive(BigDecimal a, BigDecimal b) {
        return a != null && b != null && a.signum() > 0 && a.compareTo(b) == 0;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrReinspectionValidator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,148 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdItemMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderSourceTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcNcrMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
/** å…³é—­è¿”å·¥/返修 NCR å‰ï¼Œæ ¸å®žæ“ä½œè€…明确指定的整批复检证据。 */
@Component
public class MesQcNcrReinspectionValidator {
    @Resource private MesProWorkOrderMapper workOrderMapper;
    @Resource private MesQcIpqcMapper ipqcMapper;
    @Resource private MesMdItemMapper itemMapper;
    @Resource private MesWmProductProduceLineMapper produceLineMapper;
    @Resource private MesWmProductProduceMapper produceMapper;
    @Resource private MesWmTransactionMapper transactionMapper;
    @Resource private MesQcNcrMapper ncrMapper;
    public void validate(MesQcNcrDO ncr, Long reinspectionIpqcId) {
        if (reinspectionIpqcId == null) {
            throw new ServiceException(400, "返工/返修关闭前请选择关联产出的整批复检单");
        }
        // å½“前订单仅持有来源编码;存在多个匹配项时不能猜测关联。
        List<MesProWorkOrderDO> orders = workOrderMapper.selectList(new LambdaQueryWrapperX<MesProWorkOrderDO>()
                .eq(MesProWorkOrderDO::getOrderSourceType, MesProWorkOrderSourceTypeEnum.STORE.getType())
                .eq(MesProWorkOrderDO::getOrderSourceCode, ncr.getCode()));
        if (orders.size() != 1) {
            throw new ServiceException(400, "无法唯一确定此不合格单对应的返工/返修生产订单,请先核对来源编码");
        }
        MesProWorkOrderDO order = orders.get(0);
        if (!Objects.equals(order.getStatus(), MesProWorkOrderStatusEnum.FINISHED.getStatus())
                || order.getFinishDate() == null) {
            throw new ServiceException(400, "返工/返修生产订单尚未完成或缺少完成时间,不能关闭");
        }
        MesMdItemDO product = itemMapper.selectById(order.getProductId());
        if (product == null || product.getMdmItemId() == null
                || !Objects.equals(product.getMdmItemId(), ncr.getItemId())) {
            throw new ServiceException(400, "返工订单产品与不合格物料的映射不明确,请先核对产品主数据");
        }
        MesQcIpqcDO qc = ipqcMapper.selectById(reinspectionIpqcId);
        // å‡ºè´§è¿”工同样走报工产出复检:返工工单完成后,需绑定其报工产出行的过程检验单
        if (!Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())) {
            validateLegacy(qc, order, ncr);
            return;
        }
        if (qc == null || !Objects.equals(qc.getWorkOrderId(), order.getId())
                || !Objects.equals(qc.getItemId(), order.getProductId())
                || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)
                || qc.getSourceDocId() == null || qc.getSourceLineId() == null) {
            throw new ServiceException(400, "请选择关联返工订单报工产出行的过程检验单作为复检证据");
        }
        if (ncr.getHandleDate() == null || qc.getCreateTime() == null || qc.getCreateTime().isBefore(ncr.getHandleDate())
                || qc.getInspectDate() == null || qc.getInspectDate().isBefore(ncr.getHandleDate())) {
            throw new ServiceException(400, "复检单须在返工处置后创建并检验,请核对检验日期");
        }
        BigDecimal quantity = ncr.getUnqualifiedQuantity();
        if (!Objects.equals(qc.getStatus(), MesQcStatusEnum.FINISHED.getStatus())
                || quantity == null || quantity.signum() <= 0
                || qc.getCheckQuantity() == null || qc.getCheckQuantity().compareTo(quantity) != 0
                || qc.getQualifiedQuantity() == null || qc.getQualifiedQuantity().signum() < 0
                || qc.getUnqualifiedQuantity() == null || qc.getUnqualifiedQuantity().signum() < 0
                || qc.getQualifiedQuantity().add(qc.getUnqualifiedQuantity()).compareTo(quantity) != 0
                || !Objects.equals(qc.getCheckResult(), qc.getUnqualifiedQuantity().signum() == 0 ? 1 : 2)) {
            throw new ServiceException(400, "复检必须已完成,且合格与不合格数量之和须等于不合格单数量");
        }
        MesWmProductProduceLineDO line = produceLineMapper.selectById(qc.getSourceLineId());
        MesWmProductProduceDO produce = line == null ? null : produceMapper.selectById(line.getProduceId());
        if (line == null || produce == null || !Objects.equals(line.getFeedbackId(), qc.getSourceDocId())
                || !Objects.equals(produce.getWorkOrderId(), order.getId())
                || !Objects.equals(line.getItemId(), qc.getItemId())
                || !Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.PASS.getStatus())
                || line.getQuantity() == null || line.getQuantity().compareTo(qc.getQualifiedQuantity()) != 0) {
            throw new ServiceException(400, "复检产出行与返工订单或合格数量不一致");
        }
        List<MesWmTransactionDO> flows = transactionMapper.selectList(new LambdaQueryWrapperX<MesWmTransactionDO>()
                .eq(MesWmTransactionDO::getBizType, MesBizTypeConstants.WM_PRODUCT_PRODUCE)
                .eq(MesWmTransactionDO::getBizId, produce.getId())
                .eq(MesWmTransactionDO::getBizLineId, line.getId())
                .eq(MesWmTransactionDO::getWorkOrderId, order.getId())
                .eq(MesWmTransactionDO::getItemId, qc.getItemId())
                .eq(MesWmTransactionDO::getType, MesWmTransactionTypeEnum.IN.getType()));
        BigDecimal stocked = flows.stream().map(MesWmTransactionDO::getQuantity)
                .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
        if (stocked.compareTo(qc.getQualifiedQuantity()) != 0) {
            throw new ServiceException(400, "返工复检合格产出的入库流水数量不匹配,暂不能关闭");
        }
        if (qc.getUnqualifiedQuantity().signum() > 0) {
            List<MesQcNcrDO> children = ncrMapper.selectList(new LambdaQueryWrapperX<MesQcNcrDO>()
                    .eq(MesQcNcrDO::getQcType, MesQcTypeEnum.IPQC.getType())
                    .eq(MesQcNcrDO::getQcId, qc.getId()));
            if (children.size() != 1 || !Objects.equals(children.get(0).getItemId(), ncr.getItemId())
                    || children.get(0).getUnqualifiedQuantity() == null
                    || children.get(0).getUnqualifiedQuantity().compareTo(qc.getUnqualifiedQuantity()) != 0) {
                throw new ServiceException(400, "复检不合格数量须全部移交至唯一的新不合格处理单,请核对物料和数量");
            }
        }
    }
    /** å…¶ä»–检验来源保持原有关闭规则。 */
    private void validateLegacy(MesQcIpqcDO qc, MesProWorkOrderDO order, MesQcNcrDO ncr) {
        if (qc == null || !Objects.equals(qc.getWorkOrderId(), order.getId())
                || !Objects.equals(qc.getItemId(), order.getProductId())
                || qc.getSourceDocType() != null || qc.getSourceDocId() != null || qc.getSourceLineId() != null) {
            throw new ServiceException(400, "请选择该返工订单产品的独立过程检验单作为复检证据");
        }
        if (qc.getCreateTime() == null || qc.getCreateTime().isBefore(order.getFinishDate())
                || qc.getInspectDate() == null || qc.getInspectDate().isBefore(order.getFinishDate())) {
            throw new ServiceException(400, "复检单须在返工订单完成后创建并检验,请核对检验日期");
        }
        BigDecimal quantity = ncr.getUnqualifiedQuantity();
        if (!Objects.equals(qc.getStatus(), MesQcStatusEnum.FINISHED.getStatus())
                || !Objects.equals(qc.getCheckResult(), 1)
                || quantity == null || quantity.signum() <= 0
                || qc.getCheckQuantity() == null || qc.getCheckQuantity().compareTo(quantity) != 0
                || qc.getQualifiedQuantity() == null || qc.getQualifiedQuantity().compareTo(quantity) != 0
                || qc.getUnqualifiedQuantity() == null || qc.getUnqualifiedQuantity().signum() != 0) {
            throw new ServiceException(400, "复检必须已完成且整批合格,检测数量和合格数量须等于不合格单数量");
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrReturnSalesLink.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.rqc.MesQcRqcDO;
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.dal.mysql.qc.rqc.MesQcRqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesDetailMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
/** åœ¨è°ƒç”¨æ–¹äº‹åŠ¡å†…æŠŠå®¢æˆ·é€€è´§çš„ä¸åˆæ ¼è¡Œæ•´è¡Œè½¬äº¤ NCR。 */
@Service
public class MesQcNcrReturnSalesLink {
    @Resource private MesQcRqcMapper rqcMapper;
    @Resource private MesWmReturnSalesMapper salesMapper;
    @Resource private MesWmReturnSalesLineMapper lineMapper;
    @Resource private MesWmReturnSalesDetailMapper detailMapper;
    public String claim(MesQcNcrDO ncr) {
        MesQcRqcDO qc = rqcMapper.selectById(ncr.getQcId());
        if (qc == null || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.WM_RETURN_SALES)
                || qc.getSourceDocId() == null || qc.getSourceLineId() == null
                || !Objects.equals(qc.getItemId(), ncr.getItemId())
                || qc.getUnqualifiedQuantity() == null || ncr.getUnqualifiedQuantity() == null
                || qc.getUnqualifiedQuantity().compareTo(ncr.getUnqualifiedQuantity()) != 0) {
            throw new ServiceException(400, "退货检验不合格处理仅支持可追溯的客户销售退货,物料和数量须与检验单一致");
        }
        MesWmReturnSalesDO sales = salesMapper.selectOne(new LambdaQueryWrapperX<MesWmReturnSalesDO>()
                .eq(MesWmReturnSalesDO::getId, qc.getSourceDocId()).last("FOR UPDATE"));
        if (sales == null || Objects.equals(sales.getStatus(), MesWmReturnSalesStatusEnum.FINISHED.getStatus())
                || Objects.equals(sales.getStatus(), MesWmReturnSalesStatusEnum.CANCELED.getStatus())) {
            throw new ServiceException(400, "来源销售退货单不存在或已完成/取消,不能转交处置");
        }
        List<MesWmReturnSalesLineDO> lines = lineMapper.selectList(new LambdaQueryWrapperX<MesWmReturnSalesLineDO>()
                .eq(MesWmReturnSalesLineDO::getReturnId, sales.getId())
                .eq(MesWmReturnSalesLineDO::getRqcId, qc.getId())
                .eq(MesWmReturnSalesLineDO::getQualityStatus, MesWmQualityStatusEnum.FAIL.getStatus())
                .last("FOR UPDATE"));
        if (lines.size() != 1) {
            throw new ServiceException(400, "无法唯一定位退货检验对应的不合格行,请核对来源关联");
        }
        MesWmReturnSalesLineDO line = lines.get(0);
        if (line.getNcrDispositionId() != null || !Objects.equals(line.getItemId(), ncr.getItemId())
                || line.getQuantity() == null || line.getQuantity().compareTo(ncr.getUnqualifiedQuantity()) != 0
                || !detailMapper.selectListByLineId(line.getId()).isEmpty()) {
            throw new ServiceException(400, "退货不合格行已被处理、数量不一致或已有入库明细,不能重复处置");
        }
        lineMapper.updateById(new MesWmReturnSalesLineDO().setId(line.getId()).setNcrDispositionId(ncr.getId()));
        return line.getBatchCode();
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrScrapService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.scrap.MesWmScrapReceiptDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.rqc.MesQcRqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.scrap.MesWmScrapReceiptMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrDispositionEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
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;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import jakarta.annotation.Resource;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
/** åªæŽ¥æ”¶å·²ç¡®è®¤æœªå…¥åº“的过程不合格产出或客户退货,不推测其他来源的库存状态。 */
@Service
public class MesQcNcrScrapService {
    @Resource private MesQcIpqcMapper ipqcMapper;
    @Resource private MesQcRqcMapper rqcMapper;
    @Resource private MesProWorkOrderMapper orderMapper;
    @Resource private MesWmProductProduceLineMapper produceLineMapper;
    @Resource private MesWmProductProduceMapper produceMapper;
    @Resource private MesWmReturnSalesLineMapper returnLineMapper;
    @Resource private MesWmScrapReceiptMapper scrapMapper;
    @Resource private MesWmTransactionMapper transactionMapper;
    @Resource private MesWmTransactionService transactionService;
    @Resource private MesWmWarehouseService warehouseService;
    @Resource private MesWmWarehouseAreaService areaService;
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO receive(MesQcNcrDO ncr, MesQcNcrDisposeReqVO req) {
        if (ncr.getQcId() == null || ncr.getItemId() == null || ncr.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity().signum() <= 0) throw error("不合格单来源、物料或数量不完整");
        Long orderId = null, batchId, sourceLineId;
        String orderCode = null, batchCode;
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())) {
            var source = validateIpqcSource(ncr);
            orderId = source.orderId(); orderCode = source.orderCode(); batchId = source.batchId();
            batchCode = source.batchCode(); sourceLineId = source.sourceLineId();
        } else if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType())) {
            var qc = rqcMapper.selectById(ncr.getQcId());
            if (qc == null || !Objects.equals(qc.getStatus(), MesQcStatusEnum.FINISHED.getStatus())
                    || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.WM_RETURN_SALES) || qc.getSourceDocId() == null
                    || qc.getSourceLineId() == null || !Objects.equals(qc.getItemId(), ncr.getItemId())
                    || !same(qc.getUnqualifiedQuantity(), ncr.getUnqualifiedQuantity()))
                throw error("仅关联客户销售退货的不合格检验可入废料,且物料数量必须一致");
            var lines = returnLineMapper.selectListByReturnId(qc.getSourceDocId()).stream()
                    .filter(l -> Objects.equals(l.getRqcId(), qc.getId())
                            && Objects.equals(l.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())
                            && Objects.equals(l.getItemId(), ncr.getItemId()) && same(l.getQuantity(), ncr.getUnqualifiedQuantity())
                            && Objects.equals(l.getBatchCode(), qc.getBatchCode())).toList();
            if (lines.size() != 1) throw error("无法唯一定位退货检验对应的不合格退货行,请核对关联");
            var line = lines.get(0);
            requireNoTransaction(MesBizTypeConstants.WM_RETURN_SALES, qc.getSourceDocId(), line.getId());
            batchId = line.getBatchId(); batchCode = line.getBatchCode(); sourceLineId = line.getId();
        } else {
            throw error("仅过程检验和客户退货检验可以入废料");
        }
        if (req.getWarehouseId() == null || req.getLocationId() == null || req.getAreaId() == null)
            throw error("请选择废料仓、库区和库位");
        var warehouse = warehouseService.validateWarehouseExists(req.getWarehouseId());
        if (!Objects.equals("废料仓", warehouse.getWarehouseType()) && !Objects.equals("废料", warehouse.getWarehouseType()))
            throw error("请选择仓库类型为废料仓的仓库");
        areaService.validateWarehouseAreaExists(req.getWarehouseId(), req.getLocationId(), req.getAreaId());
        String code = "SCR-N" + ncr.getQcType() + "-" + sourceLineId;
        if (scrapMapper.selectByCode(code) != null) throw error("该检验单的不合格品已登记废料,请勿重复处置");
        var receipt = new MesWmScrapReceiptDO();
        receipt.setCode(code); receipt.setWorkOrderId(orderId); receipt.setWorkOrderCode(orderCode);
        receipt.setItemId(ncr.getItemId()); receipt.setQuantity(ncr.getUnqualifiedQuantity());
        receipt.setWarehouseId(req.getWarehouseId()); receipt.setLocationId(req.getLocationId()); receipt.setAreaId(req.getAreaId());
        receipt.setBatchId(batchId); receipt.setBatchCode(batchCode); receipt.setReceiptTime(LocalDateTime.now());
        receipt.setRemark("来源不合格品处理单 " + ncr.getCode());
        scrapMapper.insert(receipt);
        Long tx = transactionService.createTransaction(new MesWmTransactionSaveReqDTO()
                .setType(MesWmTransactionTypeEnum.IN.getType()).setBizType(MesBizTypeConstants.WM_SCRAP_RECEIPT)
                .setBizId(receipt.getId()).setBizLineId(receipt.getId()).setBizCode(code).setWorkOrderId(orderId)
                .setItemId(receipt.getItemId()).setQuantity(receipt.getQuantity()).setBatchId(batchId).setBatchCode(batchCode)
                .setWarehouseId(receipt.getWarehouseId()).setLocationId(receipt.getLocationId()).setAreaId(receipt.getAreaId())
                .setReceiptTime(receipt.getReceiptTime()).setForceCreateNewStock(true));
        receipt.setTransactionId(tx); scrapMapper.updateById(receipt);
        var result = new MesQcNcrDisposeRespVO();
        result.setDisposition(MesNcrDispositionEnum.SCRAP.getType()); result.setDocType("SCRAP_RECEIPT");
        result.setDocId(receipt.getId()); result.setDocCode(code); result.setDocName("废料入库单");
        result.setMessage("已登记废料入库单[" + code + "]并入废料仓"); return result;
    }
    /** è®©æ­¥å…¥åº“与报废必须复用同一不合格产出来源和批次,调用者负责事务及来源核销。 */
    public IpqcSource validateIpqcSource(MesQcNcrDO ncr) {
        if (ncr.getQcId() == null || ncr.getItemId() == null || ncr.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity().signum() <= 0 || !Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType()))
            throw error("不合格单过程检验来源、物料或数量不完整");
        var qc = ipqcMapper.selectById(ncr.getQcId());
        if (qc == null || !Objects.equals(qc.getStatus(), MesQcStatusEnum.FINISHED.getStatus())
                || !Objects.equals(qc.getSourceDocType(), MesBizTypeConstants.PRO_FEEDBACK)
                || qc.getSourceDocId() == null || qc.getSourceLineId() == null
                || !Objects.equals(qc.getItemId(), ncr.getItemId()) || !same(qc.getUnqualifiedQuantity(), ncr.getUnqualifiedQuantity()))
            throw error("过程检验未关联完整报工产出或不合格数量不一致,不能入库");
        var original = produceLineMapper.selectById(qc.getSourceLineId());
        if (original == null || original.getProduceId() == null || !Objects.equals(original.getFeedbackId(), qc.getSourceDocId()))
            throw error("过程检验原产出行不存在或报工不一致");
        var produce = produceMapper.selectById(original.getProduceId());
        if (produce == null || !Objects.equals(produce.getFeedbackId(), qc.getSourceDocId())
                || qc.getWorkOrderId() == null || !Objects.equals(produce.getWorkOrderId(), qc.getWorkOrderId()))
            throw error("过程检验、报工产出和生产工单关联不一致");
        var candidates = produceLineMapper.selectListByFeedbackId(qc.getSourceDocId()).stream()
                .filter(l -> Objects.equals(l.getProduceId(), original.getProduceId())
                        && Objects.equals(l.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())
                        && Objects.equals(l.getItemId(), ncr.getItemId()) && same(l.getQuantity(), ncr.getUnqualifiedQuantity())
                        && Objects.equals(l.getBatchId(), original.getBatchId()) && Objects.equals(l.getBatchCode(), original.getBatchCode()))
                .toList();
        if (candidates.size() != 1) throw error("无法唯一定位未入库的不合格产出行");
        var line = candidates.get(0);
        requireNoTransaction(MesBizTypeConstants.WM_PRODUCT_PRODUCE, line.getProduceId(), line.getId());
        var order = qc.getWorkOrderId() == null ? null : orderMapper.selectById(qc.getWorkOrderId());
        if (order == null) throw error("过程检验生产订单不存在");
        return new IpqcSource(order.getId(), order.getCode(), line.getId(), line.getBatchId(), line.getBatchCode());
    }
    public record IpqcSource(Long orderId, String orderCode, Long sourceLineId, Long batchId, String batchCode) {}
    /** æ¡ä»¶æ›´æ–°æŒæœ‰è¡Œé”ç›´åˆ°äº‹åŠ¡ç»“æŸï¼Œå’Œåº“å­˜ä¸šåŠ¡ä¸€åŒæäº¤æˆ–å›žæ»šã€‚ */
    public void claimIpqcSource(MesQcNcrDO ncr, IpqcSource source) {
        if (ncr.getId() == null || source == null || source.sourceLineId() == null) throw error("不合格产出来源不完整");
        LambdaUpdateWrapper<MesWmProductProduceLineDO> claim = new LambdaUpdateWrapper<MesWmProductProduceLineDO>()
                .set(MesWmProductProduceLineDO::getNcrDispositionId, ncr.getId())
                .eq(MesWmProductProduceLineDO::getId, source.sourceLineId())
                .eq(MesWmProductProduceLineDO::getQualityStatus, MesWmQualityStatusEnum.FAIL.getStatus())
                .eq(MesWmProductProduceLineDO::getItemId, ncr.getItemId())
                .eq(MesWmProductProduceLineDO::getQuantity, ncr.getUnqualifiedQuantity())
                .isNull(MesWmProductProduceLineDO::getNcrDispositionId);
        if (source.batchId() == null) claim.isNull(MesWmProductProduceLineDO::getBatchId);
        else claim.eq(MesWmProductProduceLineDO::getBatchId, source.batchId());
        if (source.batchCode() == null) claim.isNull(MesWmProductProduceLineDO::getBatchCode);
        else claim.eq(MesWmProductProduceLineDO::getBatchCode, source.batchCode());
        int changed = produceLineMapper.update(null, claim);
        if (changed != 1) throw error("不合格产出行已处置或发生变化,请刷新后核对");
    }
    private void requireNoTransaction(Integer type, Long docId, Long lineId) {
        if (transactionMapper.selectCount(new LambdaQueryWrapperX<MesWmTransactionDO>()
                .eq(MesWmTransactionDO::getBizType, type).eq(MesWmTransactionDO::getBizId, docId)
                .eq(MesWmTransactionDO::getBizLineId, lineId)) > 0)
            throw error("来源不合格行已有库存流水,请先对账,不能重复入库");
    }
    private static boolean same(BigDecimal a, BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; }
    private static ServiceException error(String message) { return new ServiceException(400, message); }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrService.java
@@ -79,7 +79,7 @@
     * @param id        ç¼–号
     * @param bpmResult BPM å®¡æ‰¹ç»“æžœ
     */
    void updateNcrAuditStatus(Long id, Integer bpmResult);
    void updateNcrAuditStatus(Long id, String processInstanceId, Integer bpmResult);
    /**
     * å…³é—­ä¸åˆæ ¼å“å¤„理单
@@ -87,7 +87,7 @@
     * @param id           ç¼–号
     * @param closeOpinion å…³é—­æ„è§
     */
    void closeNcr(Long id, String closeOpinion);
    void closeNcr(Long id, String closeOpinion, Long reinspectionIpqcId);
    /**
     * æ ¡éªŒä¸åˆæ ¼å“å¤„理单存在
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrServiceImpl.java
@@ -85,12 +85,29 @@
     */
    private static final Set<Integer> DISPOSITION_ALLOWED_TYPES = Set.of(
            MesNcrDispositionEnum.SCRAP.getType(),
            MesNcrDispositionEnum.RETURN.getType(),
            MesNcrDispositionEnum.REPRODUCTION.getType(),
            MesNcrDispositionEnum.REWORK.getType(),
            MesNcrDispositionEnum.CONCESSION.getType(),
            MesNcrDispositionEnum.REPAIR.getType());
    @Resource
    private MesQcNcrMapper ncrMapper;
    @Resource
    private MesQcNcrReinspectionValidator reinspectionValidator;
    @Resource
    private MesQcNcrStockClosureGuard stockClosureGuard;
    @Resource private MesQcNcrScrapService scrapService;
    @Resource private MesQcNcrReturnSalesLink returnSalesLink;
    @Resource private MesQcPurchaseReturnService purchaseReturnService;
    @Resource private MesQcPurchaseConcessionService purchaseConcessionService;
    @Resource private MesQcNcrOqcSourceClaim oqcSourceClaim;
    @Resource private MesQcNcrOqcConcessionService oqcConcessionService;
    @Resource private MesQcNcrOqcReproductionService oqcReproductionService;
    @Resource private MesQcNcrOqcReworkService oqcReworkService;
    @Resource
    private MdmItemApi mdmItemApi;
@@ -170,6 +187,11 @@
    public void updateNcr(MesQcNcrSaveReqVO updateReqVO) {
        // 1. æ ¡éªŒå­˜åœ¨ + åªå…è®¸å¾…评审状态编辑
        MesQcNcrDO ncr = validateNcrStatusPendingReview(updateReqVO.getId());
        if (ncr.getQcId() != null && (!Objects.equals(ncr.getItemId(), updateReqVO.getItemId())
                || ncr.getUnqualifiedQuantity() == null || updateReqVO.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity().compareTo(updateReqVO.getUnqualifiedQuantity()) != 0)) {
            throw new ServiceException(400, "来源质检单生成的不合格物料和数量不能修改");
        }
        validateNcrCodeUnique(updateReqVO.getId(), updateReqVO.getCode());
        validateItemExists(updateReqVO.getItemId());
@@ -185,7 +207,10 @@
    @Transactional(rollbackFor = Exception.class)
    public void deleteNcr(Long id) {
        // æ ¡éªŒå­˜åœ¨ + åªå…è®¸åˆ é™¤å¾…评审状态
        validateNcrStatusPendingReview(id);
        MesQcNcrDO ncr = validateNcrStatusPendingReview(id);
        if (ncr.getQcId() != null) {
            throw new ServiceException(400, "来源质检单生成的不合格处理单不能删除");
        }
        ncrMapper.deleteById(id);
    }
@@ -194,8 +219,9 @@
    public void reviewNcr(Long id, Integer disposition, Long responsibleDeptId,
                           Integer defectLevel, String reviewOpinion) {
        // 1. æ ¡éªŒå­˜åœ¨ + å¾…评审状态
        validateNcrStatusPendingReview(id);
        MesQcNcrDO ncr = validateNcrStatusPendingReview(id);
        validateDisposition(disposition);
        validateDispositionForSource(ncr, disposition);
        // 2. æ›´æ–°è¯„审信息
        MesQcNcrDO updateObj = new MesQcNcrDO()
@@ -216,12 +242,13 @@
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO disposeNcr(MesQcNcrDisposeReqVO disposeReqVO) {
        // 1. æ ¡éªŒå­˜åœ¨ + å·²è¯„审(审批通过)状态
        MesQcNcrDO ncr = validateNcrExists(disposeReqVO.getId());
        MesQcNcrDO ncr = validateNcrExistsForUpdate(disposeReqVO.getId());
        if (!MesNcrStatusEnum.REVIEWED.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_DISPOSE_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        // 2. å¤„置方式必填,且必须是合法枚举值
        validateDisposition(disposeReqVO.getDisposition());
        validateDispositionForSource(ncr, disposeReqVO.getDisposition());
        // 3. ä¸åˆæ ¼æ•°é‡å¿…须大于 0,否则无法生成入库单 / ç”Ÿäº§è®¢å•
        if (ncr.getUnqualifiedQuantity() == null
                || ncr.getUnqualifiedQuantity().compareTo(BigDecimal.ZERO) <= 0) {
@@ -230,10 +257,71 @@
        // 4. æŒ‰å¤„置方式联动生成业务单据
        MesQcNcrDisposeRespVO respVO;
        if (Objects.equals(MesNcrDispositionEnum.CONCESSION.getType(), disposeReqVO.getDisposition())
                || Objects.equals(MesNcrDispositionEnum.SCRAP.getType(), disposeReqVO.getDisposition())) {
            // è®©æ­¥æ”¾è¡Œ / æŠ¥åºŸï¼šç”Ÿæˆæ‚项入库单并自动完成入库
        MesQcNcrScrapService.IpqcSource processSource = null;
        MesQcNcrOqcSourceClaim.OqcClaimedSource oqcSource = null;
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())) {
            oqcSource = oqcSourceClaim.claim(ncr);
        }
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())) {
            processSource = scrapService.validateIpqcSource(ncr);
            scrapService.claimIpqcSource(ncr, processSource);
            if (StrUtil.isNotBlank(processSource.batchCode()) && StrUtil.isNotBlank(disposeReqVO.getBatchCode())
                    && !Objects.equals(processSource.batchCode(), StrUtil.trim(disposeReqVO.getBatchCode()))) {
                throw new ServiceException(400, "处置批次必须与过程检验原批次一致");
            }
            if (StrUtil.isNotBlank(processSource.batchCode())) disposeReqVO.setBatchCode(processSource.batchCode());
        }
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType())) {
            String originalBatch = returnSalesLink.claim(ncr);
            if (StrUtil.isNotBlank(originalBatch) && StrUtil.isNotBlank(disposeReqVO.getBatchCode())
                    && !Objects.equals(originalBatch, StrUtil.trim(disposeReqVO.getBatchCode()))) {
                throw new ServiceException(400, "处置批次必须与客户退货原批次一致");
            }
            if (StrUtil.isNotBlank(originalBatch)) disposeReqVO.setBatchCode(originalBatch);
        }
        if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.RETURN.getType())) {
            var doc = purchaseReturnService.createForNcr(ncr);
            respVO = new MesQcNcrDisposeRespVO();
            respVO.setDisposition(disposeReqVO.getDisposition());
            respVO.setDocType("PURCHASE_RETURN");
            respVO.setDocId(doc.getId());
            respVO.setDocCode(doc.getCode());
            respVO.setDocName("采购退货单");
            respVO.setMessage("采购退货单已生成,请由仓库确认退货");
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.REPRODUCTION.getType())) {
            respVO = processSource != null
                    ? oqcReproductionService.reproduceIpqc(ncr, disposeReqVO.getDisposition(), processSource)
                    : oqcReproductionService.reproduce(ncr, disposeReqVO.getDisposition());
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                && Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())) {
            respVO = purchaseConcessionService.dispose(ncr, disposeReqVO);
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                && Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())) {
            oqcConcessionService.release(ncr, disposeReqVO);
            respVO = new MesQcNcrDisposeRespVO();
            respVO.setDisposition(disposeReqVO.getDisposition());
            respVO.setDocType("PRODUCT_SALES");
            MesQcOqcDO sourceOqc = oqcService.getOqc(ncr.getQcId());
            respVO.setDocId(sourceOqc.getSourceDocId());
            respVO.setDocCode(sourceOqc.getSourceDocCode());
            respVO.setDocName("销售出库单");
            respVO.setMessage("出货让步已完成原销售出库单拣货及出库");
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.SCRAP.getType())
                && (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())
                || Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType()))) {
            respVO = scrapService.receive(ncr, disposeReqVO);
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                && (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())
                || Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType()))) {
            respVO = createMiscReceiptForDispose(ncr, disposeReqVO);
        } else if (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())
                || Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.SCRAP.getType())) {
            stockClosureGuard.validateBeforeDispose(disposeReqVO.getDisposition());
            throw new ServiceException(400, "该来源暂不支持此处置方式");
        } else if (oqcSource != null && (Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.REWORK.getType())
                || Objects.equals(disposeReqVO.getDisposition(), MesNcrDispositionEnum.REPAIR.getType()))) {
            // å‡ºè´§è¿”å·¥/返修:扣减原成品库存 â†’ å–消原销售出库单 â†’ å…‹éš†è¿”工工单
            respVO = oqcReworkService.dispose(ncr, oqcSource, disposeReqVO);
        } else {
            // è¿”å·¥ / è¿”修:生成自行生产类型的生产订单
            respVO = createReworkWorkOrderForDispose(ncr, disposeReqVO);
@@ -462,7 +550,7 @@
        // è¯¥æ–¹æ³•会把所有异常统一包成 RuntimeException,因此这里不能直接 catch ServiceException,
        // å¿…须沿 cause é“¾åˆ¤æ–­æ˜¯å¦ä¸ºã€Œæµç¨‹å®šä¹‰ä¸å­˜åœ¨ã€ã€‚
        // 1. æ ¡éªŒå­˜åœ¨ + å¾…评审(草稿/审批驳回后)状态
        MesQcNcrDO ncr = validateNcrExists(id);
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.PENDING_REVIEW.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_SUBMIT_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
@@ -519,10 +607,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateNcrAuditStatus(Long id, Integer bpmResult) {
    public void updateNcrAuditStatus(Long id, String processInstanceId, Integer bpmResult) {
        // 1. æ ¡éªŒå­˜åœ¨ + å®¡æ‰¹ä¸­ï¼ˆé¿å…å¹¶å‘或重复回调覆盖已处置/已关闭状态)
        MesQcNcrDO ncr = validateNcrExists(id);
        if (!MesNcrStatusEnum.APPROVING.getStatus().equals(ncr.getStatus())) {
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.APPROVING.getStatus().equals(ncr.getStatus())
                || StrUtil.isBlank(processInstanceId)
                || !Objects.equals(processInstanceId, ncr.getProcessInstanceId())) {
            return;
        }
@@ -545,11 +635,38 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void closeNcr(Long id, String closeOpinion) {
    public void closeNcr(Long id, String closeOpinion, Long reinspectionIpqcId) {
        // 1. æ ¡éªŒå­˜åœ¨ + å·²å¤„置状态
        MesQcNcrDO ncr = validateNcrExists(id);
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.DISPOSED.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_CLOSE_STATUS_INVALID, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
        if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.RETURN.getType())
                && Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())) {
            purchaseReturnService.validateConfirmed(ncr.getId());
        } else if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())
                && Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())) {
            oqcConcessionService.validateBeforeClose(ncr);
        } else if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())
                && Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())) {
            // é‡‡è´­è®©æ­¥çš„入库和 ERP å›žå†™ä¸Žå¤„置在同一事务完成。
        } else if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REPRODUCTION.getType())) {
            throw new ServiceException(400, "重新生产后需完成复检并处理原出库单,暂不能关闭");
        } else if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())
                && (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REWORK.getType())
                || Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REPAIR.getType()))) {
            oqcReworkService.validateBeforeClose(ncr);
        } else if (!Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.RQC.getType())) {
            stockClosureGuard.validateBeforeClose(ncr);
        }
        if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REWORK.getType())
                || Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.REPAIR.getType())) {
            reinspectionValidator.validate(ncr, reinspectionIpqcId);
        } else {
            reinspectionIpqcId = null;
        }
        // 2. æ›´æ–°å…³é—­ä¿¡æ¯
@@ -558,6 +675,7 @@
                .setCloseUserId(getLoginUserId())
                .setCloseDate(LocalDateTime.now())
                .setCloseOpinion(closeOpinion)
                .setReinspectionIpqcId(reinspectionIpqcId)
                .setStatus(MesNcrStatusEnum.CLOSED.getStatus()));
    }
@@ -572,11 +690,19 @@
        return ncr;
    }
    private MesQcNcrDO validateNcrExistsForUpdate(Long id) {
        MesQcNcrDO ncr = ncrMapper.selectByIdForUpdate(id);
        if (ncr == null) {
            throw exception(QC_NCR_NOT_EXISTS, id);
        }
        return ncr;
    }
    /**
     * æ ¡éªŒ NCR å­˜åœ¨ä¸”为待评审状态
     */
    private MesQcNcrDO validateNcrStatusPendingReview(Long id) {
        MesQcNcrDO ncr = validateNcrExists(id);
        MesQcNcrDO ncr = validateNcrExistsForUpdate(id);
        if (!MesNcrStatusEnum.PENDING_REVIEW.getStatus().equals(ncr.getStatus())) {
            throw exception(QC_NCR_STATUS_NOT_PENDING, ncr.getCode(), resolveStatusName(ncr.getStatus()));
        }
@@ -595,6 +721,22 @@
        }
    }
    private void validateDispositionForSource(MesQcNcrDO ncr, Integer disposition) {
        if (Objects.equals(ncr.getQcType(), MesQcTypeEnum.IQC.getType())) {
            if (!Objects.equals(disposition, MesNcrDispositionEnum.RETURN.getType())
                    && !Objects.equals(disposition, MesNcrDispositionEnum.CONCESSION.getType())) {
                throw new ServiceException(400, "采购检验不合格仅支持退货或让步放行");
            }
        } else if (Objects.equals(disposition, MesNcrDispositionEnum.RETURN.getType())) {
            throw new ServiceException(400, "只有采购检验不合格支持采购退货");
        }
        if (Objects.equals(disposition, MesNcrDispositionEnum.REPRODUCTION.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.OQC.getType())
                && !Objects.equals(ncr.getQcType(), MesQcTypeEnum.IPQC.getType())) {
            throw new ServiceException(400, "重新生产仅支持过程或出货检验不合格");
        }
    }
    /**
     * çŠ¶æ€å€¼è½¬åç§°ï¼Œè§£æžå¤±è´¥å›žé€€ä¸º ID å­—符串
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrStockClosureGuard.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrDispositionEnum;
import org.springframework.stereotype.Component;
import java.util.Objects;
/** åº“存隔离和销毁闭环尚未建立前,禁止通过入库伪造完成处置。 */
@Component
public class MesQcNcrStockClosureGuard {
    public void validateBeforeDispose(Integer disposition) {
        if (Objects.equals(disposition, MesNcrDispositionEnum.SCRAP.getType())) {
            throw new ServiceException(400, "报废处置暂不可执行:需先建立不合格品隔离入库、销毁确认及销毁出库闭环,避免重复入库或账实不符");
        }
        if (Objects.equals(disposition, MesNcrDispositionEnum.CONCESSION.getType())) {
            throw new ServiceException(400, "让步放行暂不可执行:需先完成来源单据、原批次和库存的放行闭环,避免重复入库或放行错误批次");
        }
    }
    public void validateBeforeClose(MesQcNcrDO ncr) {
        if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.SCRAP.getType())) {
            throw new ServiceException(400, "报废处理单暂不可关闭:尚未建立隔离库存的销毁确认和销毁出库闭环,请保留单据等待处理");
        }
        if (Objects.equals(ncr.getDisposition(), MesNcrDispositionEnum.CONCESSION.getType())) {
            throw new ServiceException(400, "让步放行处理单暂不可关闭:尚未完成来源单据、原批次和库存的放行核验,请保留单据等待处理");
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseConcessionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.itemreceipt.vo.MesWmItemReceiptSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.itemreceipt.vo.line.MesWmItemReceiptLineSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.itemreceipt.vo.detail.MesWmItemReceiptDetailSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseConcessionDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseConcessionMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseReturnMapper;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.MesWmItemReceiptService;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.MesWmItemReceiptLineService;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.MesWmItemReceiptDetailService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/** è®©æ­¥æ•°é‡ç»é‡‡è´­å…¥åº“过账,合格余量仍由原到货单入库。 */
@Service
public class MesQcPurchaseConcessionService {
    @Resource private MesQcNcrPurchaseSourceValidator validator;
    @Resource private MesQcPurchaseConcessionMapper concessionMapper;
    @Resource private MesQcPurchaseReturnMapper returnMapper;
    @Resource private MesWmItemReceiptService receipts;
    @Resource private MesWmItemReceiptLineService lines;
    @Resource private MesWmItemReceiptDetailService details;
    @Resource private MesQcArrivalDispositionService arrivalDispositionService;
    /** å¿…须由锁定 NCR çš„处置事务调用。 */
    @Transactional(rollbackFor = Exception.class)
    public MesQcNcrDisposeRespVO dispose(MesQcNcrDO ncr, MesQcNcrDisposeReqVO request) {
        var source = validator.validate(ncr);
        if (returnMapper.selectByIqcId(ncr.getQcId()) != null || concessionMapper.selectByIqcId(ncr.getQcId()) != null)
            throw new ServiceException(400, "该来料检验已有采购处置单,不能重复处置");
        if (source.notice().getVendorId() == null || source.notice().getPurchaseOrderId() == null
                || source.line().getPurchaseOrderItemId() == null)
            throw new ServiceException(400, "到货单缺少供应商或采购订单明细,无法生成采购入库");
        if (request.getWarehouseId() == null || request.getLocationId() == null || request.getAreaId() == null)
            throw new ServiceException(400, "让步入库请选择仓库、库区和库位");
        String code = "NCR-IR-" + ncr.getId();
        var head = new MesWmItemReceiptSaveReqVO();
        head.setCode(code); head.setName("让步入库-" + ncr.getCode());
        head.setIqcId(ncr.getQcId()); head.setVendorId(source.notice().getVendorId());
        head.setPurchaseOrderCode(source.notice().getPurchaseOrderCode());
        head.setReceiptDate(LocalDateTime.now());
        head.setRemark("来料让步入库,来源不合格单 " + ncr.getCode());
        Long receiptId = receipts.createItemReceipt(head);
        var line = new MesWmItemReceiptLineSaveReqVO();
        line.setReceiptId(receiptId); line.setItemId(ncr.getItemId());
        line.setPurchaseOrderItemId(source.line().getPurchaseOrderItemId());
        line.setReceivedQuantity(ncr.getUnqualifiedQuantity()); line.setBatchCode(request.getBatchCode());
        line.setLotNumber(source.iqc().getVendorBatch());
        Long lineId = lines.createItemReceiptLine(line);
        var storedLine = lines.getItemReceiptLine(lineId);
        var detail = new MesWmItemReceiptDetailSaveReqVO();
        detail.setReceiptId(receiptId); detail.setLineId(lineId); detail.setItemId(ncr.getItemId());
        detail.setQuantity(ncr.getUnqualifiedQuantity()); detail.setBatchId(storedLine.getBatchId());
        detail.setWarehouseId(request.getWarehouseId()); detail.setLocationId(request.getLocationId()); detail.setAreaId(request.getAreaId());
        details.createItemReceiptDetail(detail);
        receipts.submitItemReceipt(receiptId); receipts.stockItemReceipt(receiptId); receipts.finishItemReceipt(receiptId);
        var record = new MesQcPurchaseConcessionDO();
        record.setNcrId(ncr.getId()); record.setIqcId(ncr.getQcId());
        record.setNoticeId(source.notice().getId()); record.setNoticeLineId(source.line().getId());
        record.setPurchaseOrderItemId(source.line().getPurchaseOrderItemId());
        record.setReceiptId(receiptId); record.setQuantity(ncr.getUnqualifiedQuantity());
        concessionMapper.insert(record);
        arrivalDispositionService.refresh(source.notice().getId());
        var response = new MesQcNcrDisposeRespVO();
        response.setDisposition(request.getDisposition()); response.setDocType("ITEM_RECEIPT");
        response.setDocId(receiptId); response.setDocCode(code); response.setDocName(head.getName());
        response.setMessage("让步数量已完成采购入库,原合格数量继续由到货单入库");
        return response;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseReturnService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseReturnDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseReturnMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseConcessionMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Objects;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Service
public class MesQcPurchaseReturnService {
    @Resource private MesQcNcrPurchaseSourceValidator sourceValidator;
    @Resource private MesQcPurchaseReturnMapper mapper;
    @Resource private MesQcPurchaseConcessionMapper concessionMapper;
    @Resource private MesQcArrivalDispositionService arrivalDispositionService;
    /** å¿…须由已锁定 NCR çš„处置事务调用。 */
    @Transactional(rollbackFor = Exception.class)
    public MesQcPurchaseReturnDO createForNcr(MesQcNcrDO ncr) {
        if (mapper.selectByNcrId(ncr.getId()) != null)
            throw new ServiceException(400, "该不合格单已生成采购退货单");
        var source = sourceValidator.validate(ncr);
        if (concessionMapper.selectByIqcId(ncr.getQcId()) != null || mapper.selectByIqcId(ncr.getQcId()) != null)
            throw new ServiceException(400, "该来料检验已有采购处置单,不能重复处置");
        if (source.notice().getVendorId() == null || source.notice().getPurchaseOrderId() == null
                || source.line().getPurchaseOrderItemId() == null)
            throw new ServiceException(400, "到货单缺少供应商、采购订单或采购明细,无法生成可追溯退货单");
        MesQcPurchaseReturnDO doc = new MesQcPurchaseReturnDO();
        doc.setCode("NCR-RET-" + ncr.getId());
        doc.setNcrId(ncr.getId());
        doc.setIqcId(ncr.getQcId());
        doc.setNoticeId(source.notice().getId());
        doc.setNoticeLineId(source.line().getId());
        doc.setPurchaseOrderId(source.notice().getPurchaseOrderId());
        doc.setPurchaseOrderItemId(source.line().getPurchaseOrderItemId());
        doc.setVendorId(source.notice().getVendorId());
        doc.setItemId(ncr.getItemId());
        doc.setVendorBatch(source.iqc().getVendorBatch());
        doc.setQuantity(ncr.getUnqualifiedQuantity());
        doc.setStatus(0);
        mapper.insert(doc);
        return doc;
    }
    public MesQcPurchaseReturnDO getByNcrId(Long ncrId) { return mapper.selectByNcrId(ncrId); }
    public PageResult<MesQcPurchaseReturnDO> getPage(PageParam page, Integer status) {
        if (status != null && status != 0 && status != 1) throw new ServiceException(400, "采购退货状态无效");
        return mapper.selectPage(page, status);
    }
    @Transactional(rollbackFor = Exception.class)
    public void confirm(Long id, String remark) {
        MesQcPurchaseReturnDO doc = mapper.selectByIdForUpdate(id);
        if (doc == null) throw new ServiceException(400, "采购退货单不存在");
        if (!Objects.equals(doc.getStatus(), 0)) throw new ServiceException(400, "采购退货单已确认退货");
        mapper.updateById(new MesQcPurchaseReturnDO().setId(id).setStatus(1)
                .setConfirmedBy(getLoginUserId()).setConfirmedAt(LocalDateTime.now()).setConfirmationRemark(remark));
        arrivalDispositionService.refresh(doc.getNoticeId());
    }
    public void validateConfirmed(Long ncrId) {
        MesQcPurchaseReturnDO doc = mapper.selectByNcrId(ncrId);
        if (doc == null || !Objects.equals(doc.getStatus(), 1))
            throw new ServiceException(400, "请先由采购仓库确认采购退货完成,再关闭不合格单");
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ncr/listener/MesQcNcrStatusListener.java
@@ -66,7 +66,7 @@
        }
        // 4. æ›´æ–°å®¡æ‰¹çŠ¶æ€ï¼ˆService å†…部会再次校验仅审批中状态可更新)
        ncrService.updateNcrAuditStatus(ncrId, event.getStatus());
        ncrService.updateNcrAuditStatus(ncrId, event.getId(), event.getStatus());
        log.info("[onEvent] NCR({}) å®¡æ‰¹çŠ¶æ€æ›´æ–°å®Œæˆï¼ŒBPM çŠ¶æ€ï¼š{}", ncrId, event.getStatus());
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/rqc/MesQcRqcServiceImpl.java
@@ -212,6 +212,8 @@
                    rqc.getQualifiedQuantity(), rqc.getUnqualifiedQuantity());
        } else if (Objects.equals(rqc.getSourceDocType(), MesBizTypeConstants.WM_RETURN_SALES)) {
            // å›žå†™é”€å”®é€€è´§å•行:拆分行 + æ›´æ–°è´¨é‡çŠ¶æ€ + è”动主单状态
            returnSalesLineService.bindRqcForFinish(rqc.getSourceLineId(), rqc.getSourceDocId(),
                    rqc.getId(), rqc.getItemId(), rqc.getCheckQuantity());
            returnSalesLineService.updateReturnSalesLineWhenRqcFinish(
                    rqc.getSourceLineId(), rqc.getSourceDocId(), rqc.getCheckResult(),
                    rqc.getQualifiedQuantity(), rqc.getUnqualifiedQuantity());
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImpl.java
@@ -48,6 +48,9 @@
public class MesWmArrivalNoticeServiceImpl implements MesWmArrivalNoticeService {
    @Resource
    private cn.iocoder.yudao.module.mes.service.qc.ncr.MesQcArrivalDispositionService arrivalDispositionService;
    @Resource
    private MesWmArrivalNoticeMapper arrivalNoticeMapper;
    @Resource
@@ -201,27 +204,8 @@
        // 2. æ›´æ–°åˆ°è´§é€šçŸ¥å•行:绑定 iqcId + è´¨æ£€ç»“æžœ + åˆæ ¼æ•°é‡
        arrivalNoticeLineService.updateArrivalNoticeLineWhenIqcFinish(lineId, iqcId, checkResult, qualifiedQuantity);
        // 3.1 åˆ¤æ–­æ˜¯å¦æ‰€æœ‰éœ€æ£€è¡Œéƒ½å·²å®Œæˆè´¨æ£€
        List<MesWmArrivalNoticeLineDO> lines = arrivalNoticeLineService.getArrivalNoticeLineListByNoticeId(id);
        boolean hasUnchecked = CollectionUtils.anyMatch(lines,
                line -> Boolean.TRUE.equals(line.getIqcCheckFlag()) && line.getIqcCheckResult() == null);
        if (hasUnchecked) {
            log.info("[updateArrivalNoticeWhenIqcFinish][到货通知单({}) è¿˜æœ‰æœªå®Œæˆæ£€éªŒçš„行,暂不更新状态]", id);
            return;
        }
        // 3.2 åˆ¤æ–­æ˜¯å¦æ‰€æœ‰éœ€æ£€è¡Œéƒ½å¯ä»¥å…¥åº“(合格或特采)
        boolean hasNonInbound = CollectionUtils.anyMatch(lines,
                line -> Boolean.TRUE.equals(line.getIqcCheckFlag())
                        && !MesQcCheckResultEnum.canInbound(line.getIqcCheckResult()));
        if (hasNonInbound) {
            log.info("[updateArrivalNoticeWhenIqcFinish][到货通知单({}) å­˜åœ¨ä¸å¯å…¥åº“的行(退货/报废),状态保持待质检]", id);
            return;
        }
        // 3.3 æ‰€æœ‰è¡Œæ£€éªŒå®Œæˆä¸”可入库,更新主表状态 PENDING_QC â†’ PENDING_RECEIPT
        arrivalNoticeMapper.updateById(new MesWmArrivalNoticeDO()
                .setId(id).setStatus(MesWmArrivalNoticeStatusEnum.PENDING_RECEIPT.getStatus()));
        // åŒæ—¶è®¤å¯ä»“库已确认的来料退货;保留原始不合格结论。
        arrivalDispositionService.refresh(id);
    }
    @Override
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptLineServiceImpl.java
@@ -182,7 +182,8 @@
        MesWmBatchGenerateReqVO batchReqVO = new MesWmBatchGenerateReqVO();
        // ä»Žå…¥åº“单行获取
        batchReqVO.setItemId(reqVO.getItemId()).setProduceDate(reqVO.getProductionDate())
                .setExpireDate(reqVO.getExpireDate()).setLotNumber(reqVO.getLotNumber());
                .setExpireDate(reqVO.getExpireDate()).setLotNumber(reqVO.getLotNumber())
                .setBatchCode(reqVO.getBatchCode());
        // ä»Žçˆ¶å•据获取
        batchReqVO.setVendorId(receipt.getVendorId()).setReceiptDate(receipt.getReceiptDate())
                .setPurchaseOrderCode(receipt.getPurchaseOrderCode());
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImpl.java
@@ -433,13 +433,19 @@
        // 6. éåŽ†è¡Œåˆ›å»ºå…¥åº“å•è¡Œ
        for (MesWmArrivalNoticeLineDO noticeLine : lines) {
            BigDecimal receivable = noticeLine.getQualifiedQuantity() != null
                    ? noticeLine.getQualifiedQuantity() : noticeLine.getArrivalQuantity();
            // æ•´è¡Œå·²é€€ä¾›åº”商时不生成零数量入库行;其余行仍正常收货。
            if (receivable == null || receivable.signum() <= 0) {
                continue;
            }
            MesWmItemReceiptLineDO receiptLine = new MesWmItemReceiptLineDO();
            receiptLine.setReceiptId(receipt.getId());
            receiptLine.setArrivalNoticeLineId(noticeLine.getId());
            receiptLine.setPurchaseOrderItemId(noticeLine.getPurchaseOrderItemId());
            receiptLine.setItemId(noticeLine.getItemId());
            // ä½¿ç”¨åˆæ ¼æ•°é‡ï¼ˆå¦‚果有 IQC åˆ™æ˜¯åˆæ ¼æ•°é‡ï¼Œå¦åˆ™æ˜¯åˆ°è´§æ•°é‡ï¼‰
            receiptLine.setReceivedQuantity(noticeLine.getQualifiedQuantity() != null ? noticeLine.getQualifiedQuantity() : noticeLine.getArrivalQuantity());
            receiptLine.setReceivedQuantity(receivable);
            // æŒ‰é‡‡è´­è®¢å•行单价快照金额,供来票/对账使用
            fillReceiptLinePrice(receiptLine);
            itemReceiptLineService.createItemReceiptLineFromArrivalNoticeLine(receiptLine);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/miscissue/MesWmMiscIssueServiceImpl.java
@@ -134,6 +134,7 @@
                .setQuantity(line.getQuantity().negate()) // å‡ºåº“数量为负数
                .setBatchId(line.getBatchId()).setBatchCode(line.getBatchCode())
                .setWarehouseId(line.getWarehouseId()).setLocationId(line.getLocationId()).setAreaId(line.getAreaId())
                .setMaterialStockId(line.getMaterialStockId()) // æŒ‡å®šåº“存记录ID,避免重新匹配扣错库存
                .setBizType(MesBizTypeConstants.WM_MISC_ISSUE).setBizId(issue.getId()).setBizCode(issue.getCode()).setBizLineId(line.getId())));
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmOqcConcessionGuard.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.mes.service.wm.productsales;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcNcrMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrDispositionEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Objects;
/** å·²è®©æ­¥çš„出货行只能从原检验批次拣货出库。 */
@Component
public class MesWmOqcConcessionGuard {
    @Resource private MesQcNcrMapper ncrMapper;
    @Resource private MesQcOqcMapper oqcMapper;
    @Resource private MesWmMaterialStockMapper stockMapper;
    private MesQcOqcDO releasedQc(MesWmProductSalesLineDO line) {
        if (line == null || line.getOqcId() == null) return null;
        Long count = ncrMapper.selectCount(new LambdaQueryWrapperX<MesQcNcrDO>()
                .eq(MesQcNcrDO::getQcType, MesQcTypeEnum.OQC.getType())
                .eq(MesQcNcrDO::getQcId, line.getOqcId())
                .eq(MesQcNcrDO::getDisposition, MesNcrDispositionEnum.CONCESSION.getType())
                .in(MesQcNcrDO::getStatus, MesNcrStatusEnum.DISPOSED.getStatus(), MesNcrStatusEnum.CLOSED.getStatus()));
        if (count == 0) return null;
        MesQcOqcDO qc = oqcMapper.selectById(line.getOqcId());
        if (qc == null || !Objects.equals(qc.getSourceLineId(), line.getId())
                || !Objects.equals(qc.getItemId(), line.getItemId()) || StrUtil.isBlank(qc.getBatchCode())) {
            throw new ServiceException(400, "让步出货的原检验、物料或批次无法核验");
        }
        return qc;
    }
    public void validate(MesWmProductSalesLineDO line, Long stockId, String batchCode, BigDecimal quantity) {
        MesQcOqcDO qc = releasedQc(line);
        if (qc == null) return;
        MesWmMaterialStockDO stock = stockId == null ? null : stockMapper.selectById(stockId);
        if (quantity == null || quantity.signum() <= 0 || stock == null || !Objects.equals(stock.getItemId(), qc.getItemId())
                || (line.getMaterialStockId() != null && !Objects.equals(line.getMaterialStockId(), stockId))
                || !Objects.equals(stock.getBatchCode(), qc.getBatchCode())
                || !Objects.equals(batchCode, qc.getBatchCode())) {
            throw new ServiceException(400, "让步出货必须使用原出货检验对应的物料和批次库存");
        }
    }
    public void validateQuantity(MesWmProductSalesLineDO line, BigDecimal picked) {
        if (releasedQc(line) == null) return;
        if (picked == null || line.getQuantity() == null || picked.compareTo(line.getQuantity()) != 0) {
            throw new ServiceException(400, "让步出货的拣货数量必须等于原检验行数量");
        }
    }
    public void validateEditable(MesWmProductSalesLineDO line, Integer salesStatus) {
        if (releasedQc(line) != null && !Objects.equals(salesStatus, MesWmProductSalesStatusEnum.APPROVING.getStatus())) {
            throw new ServiceException(400, "让步出货明细仅可在待拣货时修改,库存占用后不可变更");
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesDetailServiceImpl.java
@@ -6,12 +6,17 @@
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.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
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;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.List;
@@ -42,9 +47,15 @@
    private MesWmWarehouseAreaService warehouseAreaService;
    @Resource
    private MesWmSalesReelGuard salesReelGuard;
    @Resource
    private MesWmOqcConcessionGuard oqcConcessionGuard;
    @Resource
    private MesWmProductSalesMapper salesMapper;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createProductSalesDetail(MesWmProductSalesDetailSaveReqVO createReqVO) {
        validateEditable(createReqVO.getSalesId(), createReqVO.getLineId());
        // æ ¡éªŒæ•°æ®
        validateProductSalesDetailSaveData(createReqVO);
@@ -55,9 +66,14 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateProductSalesDetail(MesWmProductSalesDetailSaveReqVO updateReqVO) {
        // æ ¡éªŒå­˜åœ¨
        validateProductSalesDetailExists(updateReqVO.getId());
        MesWmProductSalesDetailDO existing = validateProductSalesDetailExists(updateReqVO.getId());
        if (!ObjUtil.equal(existing.getSalesId(), updateReqVO.getSalesId()) || !ObjUtil.equal(existing.getLineId(), updateReqVO.getLineId())) {
            throw new ServiceException(400, "不允许更改出库明细所属单据或行");
        }
        validateEditable(existing.getSalesId(), existing.getLineId());
        // æ ¡éªŒæ•°æ®
        validateProductSalesDetailSaveData(updateReqVO);
@@ -67,11 +83,23 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteProductSalesDetail(Long id) {
        // æ ¡éªŒå­˜åœ¨
        validateProductSalesDetailExists(id);
        MesWmProductSalesDetailDO detail = validateProductSalesDetailExists(id);
        validateEditable(detail.getSalesId(), detail.getLineId());
        // åˆ é™¤
        productSalesDetailMapper.deleteById(id);
    }
    private void validateEditable(Long salesId, Long lineId) {
        MesWmProductSalesDO sales = salesMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .eq(MesWmProductSalesDO::getId, salesId).last("FOR UPDATE"));
        MesWmProductSalesLineDO line = productSalesLineService.getProductSalesLine(lineId);
        if (sales == null || line == null || !ObjUtil.equal(line.getSalesId(), salesId)) {
            throw new ServiceException(400, "出库单或出库行不存在");
        }
        oqcConcessionGuard.validateEditable(line, sales.getStatus());
    }
    @Override
@@ -129,6 +157,7 @@
                reqVO.getMaterialStockId(), reqVO.getItemId(), reqVO.getBatchId(), reqVO.getBatchCode(),
                reqVO.getWarehouseId(), reqVO.getLocationId(), reqVO.getAreaId(), reqVO.getQuantity());
        salesReelGuard.validate(line, reqVO.getBatchId());
        oqcConcessionGuard.validate(line, reqVO.getMaterialStockId(), reqVO.getBatchCode(), reqVO.getQuantity());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -2,6 +2,8 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -9,11 +11,13 @@
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.MesWmProductSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.MesWmProductSalesSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.MesWmProductSalesShippingReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.salesnotice.MesWmSalesNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
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.MesWmProductSalesMapper;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
@@ -22,6 +26,7 @@
import cn.iocoder.yudao.module.mes.enums.wm.MesWmStockReserveBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import cn.iocoder.yudao.module.mes.service.wm.salesnotice.MesWmSalesNoticeService;
import cn.iocoder.yudao.module.mes.service.wm.stockreserve.MesWmStockReserveService;
@@ -39,6 +44,7 @@
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@@ -64,6 +70,8 @@
    @Resource
    private MesWmProductSalesDetailService productSalesDetailService;
    @Resource
    private MesWmOqcConcessionGuard oqcConcessionGuard;
    @Resource
    private CrmCustomerApi customerApi;
    @Resource
    private MesWmSalesNoticeService salesNoticeService;
@@ -81,6 +89,8 @@
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MesWmSalesReelGuard salesReelGuard;
    @Resource
    private MesMdItemService mdItemService;
    @Override
    public Long createProductSales(MesWmProductSalesSaveReqVO createReqVO) {
@@ -191,7 +201,10 @@
    @Transactional(rollbackFor = Exception.class)
    public void stockProductSales(Long id) {
        // æ ¡éªŒå­˜åœ¨
        MesWmProductSalesDO sales = validateProductSalesExists(id);
        validateProductSalesExists(id);
        MesWmProductSalesDO sales = productSalesMapper.selectOne(new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .eq(MesWmProductSalesDO::getId, id).last("FOR UPDATE"));
        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);
        }
@@ -202,9 +215,12 @@
            if (CollUtil.isEmpty(details)) {
                throw exception(WM_PRODUCT_SALES_DETAILS_EMPTY);
            }
            oqcConcessionGuard.validateQuantity(line, CollectionUtils.getSumValue(details,
                    MesWmProductSalesDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO));
            // æ ¡éªŒåº“存充足并创建占用
            for (MesWmProductSalesDetailDO detail : details) {
                salesReelGuard.validate(line, detail.getBatchId());
                oqcConcessionGuard.validate(line, detail.getMaterialStockId(), detail.getBatchCode(), detail.getQuantity());
                materialStockService.validateSelectedStock(
                        detail.getMaterialStockId(),
                        detail.getItemId(),
@@ -334,12 +350,19 @@
    private void createTransactionList(MesWmProductSalesDO sales) {
        List<MesWmProductSalesDetailDO> details = productSalesDetailService.getProductSalesDetailListBySalesId(sales.getId());
        for (MesWmProductSalesLineDO line : productSalesLineService.getProductSalesLineListBySalesId(sales.getId())) {
            BigDecimal quantity = details.stream().filter(detail -> ObjUtil.equal(detail.getLineId(), line.getId()))
                    .map(MesWmProductSalesDetailDO::getQuantity).filter(Objects::nonNull)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            oqcConcessionGuard.validateQuantity(line, quantity);
        }
        for (MesWmProductSalesDetailDO detail : details) {
            MesWmProductSalesLineDO line = productSalesLineService.getProductSalesLine(detail.getLineId());
            if (line == null || !ObjUtil.equal(line.getSalesId(), sales.getId())) {
                throw exception(WM_PRODUCT_SALES_DETAIL_LINE_NOT_MATCH);
            }
            salesReelGuard.validate(line, detail.getBatchId());
            oqcConcessionGuard.validate(line, detail.getMaterialStockId(), detail.getBatchCode(), detail.getQuantity());
        }
        wmTransactionService.createTransactionList(convertList(details, detail -> new MesWmTransactionSaveReqDTO()
                .setType(MesWmTransactionTypeEnum.OUT.getType()).setItemId(detail.getItemId())
@@ -376,11 +399,36 @@
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.CONFIRMED.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_CONFIRM);
        }
        // å‡ºè´§æ£€éªŒä¸åˆæ ¼çš„出库行必须先完成不合格品处置,禁止手工确认绕过返工/让步闭环
        List<MesWmProductSalesLineDO> lines = productSalesLineMapper.selectListBySalesId(id);
        if (CollUtil.isNotEmpty(lines)) {
            int rowNo = 0;
            for (MesWmProductSalesLineDO line : lines) {
                rowNo++;
                if (Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())
                        || line.getNcrDispositionId() != null) {
                    throw new ServiceException(400, String.format(
                            "销售出库单[%s]第[%d]行物料[%s]出货检验不合格,尚未完成不合格品处置;"
                                    + "请先在不合格品处理单中完成返工/返修/重新生产或让步放行后再确认出库",
                            sales.getCode(), rowNo, itemLabel(line.getItemId())));
                }
            }
        }
        // OQC æ£€éªŒå®Œæˆï¼Œæµè½¬åˆ°å¾…拣货
        productSalesMapper.updateById(new MesWmProductSalesDO()
                .setId(id).setStatus(MesWmProductSalesStatusEnum.APPROVING.getStatus()));
    }
    private String itemLabel(Long itemId) {
        MesMdItemDO item = itemId == null ? null : mdItemService.getItem(itemId);
        if (item == null) {
            return "ID=" + itemId;
        }
        String label = (StrUtil.blankToDefault(item.getCode(), "") + " "
                + StrUtil.blankToDefault(item.getName(), "")).trim();
        return label.isEmpty() ? "ID=" + itemId : label;
    }
    @Override
    public List<MesWmProductSalesDO> getProductSalesListByClientId(Long clientId) {
        return productSalesMapper.selectListByClientId(clientId);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineService.java
@@ -85,6 +85,9 @@
     */
    void updateQualityStatusByReturnId(Long returnId, Integer qualityStatus);
    /** é”å®šå¹¶ç»‘定来源检验,确保整行数量与检验结果对应。 */
    void bindRqcForFinish(Long lineId, Long returnId, Long rqcId, Long itemId, BigDecimal checkQuantity);
    /**
     * RQC æ£€éªŒå®ŒæˆåŽï¼Œæ ¹æ®åˆæ ¼å“/不合格品数量更新销售退货单行
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java
@@ -7,6 +7,8 @@
import cn.iocoder.yudao.module.aftersales.api.AfterSaleTicketApi;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleWarehouseReturnStageReqDTO;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLinePageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLineSaveReqVO;
@@ -208,6 +210,21 @@
    }
    @Override
    public void bindRqcForFinish(Long lineId, Long returnId, Long rqcId, Long itemId, BigDecimal checkQuantity) {
        MesWmReturnSalesLineDO line = returnSalesLineMapper.selectOne(new LambdaQueryWrapperX<MesWmReturnSalesLineDO>()
                .eq(MesWmReturnSalesLineDO::getId, lineId).last("FOR UPDATE"));
        if (line == null || !Objects.equals(line.getReturnId(), returnId)
                || !Objects.equals(line.getItemId(), itemId)
                || !Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.PENDING.getStatus())
                || (line.getRqcId() != null && !Objects.equals(line.getRqcId(), rqcId))
                || line.getQuantity() == null || checkQuantity == null
                || line.getQuantity().compareTo(checkQuantity) != 0) {
            throw new ServiceException(400, "退货检验须对应唯一待检退货行,物料和检验数量必须与退货行一致");
        }
        returnSalesLineMapper.updateById(new MesWmReturnSalesLineDO().setId(lineId).setRqcId(rqcId));
    }
    @Override
    public void updateReturnSalesLineWhenRqcFinish(Long sourceLineId, Long sourceDocId, Integer checkResult,
                                                    BigDecimal qualifiedQuantity, BigDecimal unqualifiedQuantity) {
        MesWmReturnSalesLineDO sourceLine = validateReturnSalesLineExists(sourceLineId);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java
@@ -34,6 +34,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@@ -166,7 +167,8 @@
    @Transactional(rollbackFor = Exception.class)
    public void stockReturnSales(Long id) {
        // 1.1 æ ¡éªŒå­˜åœ¨ + å¾…上架状态
        MesWmReturnSalesDO returnSales = validateReturnSalesExists(id);
        MesWmReturnSalesDO returnSales = returnSalesMapper.selectByIdForUpdate(id);
        if (returnSales == null) throw exception(WM_RETURN_SALES_NOT_EXISTS);
        if (ObjUtil.notEqual(MesWmReturnSalesStatusEnum.APPROVED.getStatus(), returnSales.getStatus())) {
            throw exception(WM_RETURN_SALES_STATUS_NOT_APPROVED);
        }
@@ -182,6 +184,12 @@
                    allDetails, MesWmReturnSalesDetailDO::getLineId);
            // æ£€æŸ¥æ¯è¡Œçš„æ˜Žç»†æ•°é‡
            for (MesWmReturnSalesLineDO line : lines) {
                if (line.getNcrDispositionId() != null && Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())) {
                    if (!detailMap.getOrDefault(line.getId(), Collections.emptyList()).isEmpty()) {
                        throw exception(WM_RETURN_SALES_LINE_NOT_QUALIFIED);
                    }
                    continue; // ä¸åˆæ ¼æ•°é‡ç”± NCR ç‹¬ç«‹å¤„理,不在原退货单再次入库
                }
                if (ObjUtil.notEqual(line.getQualityStatus(), MesWmQualityStatusEnum.PASS.getStatus())) {
                    throw exception(WM_RETURN_SALES_LINE_NOT_QUALIFIED);
                }
@@ -226,7 +234,8 @@
    @Transactional(rollbackFor = Exception.class)
    public void cancelReturnSales(Long id) {
        // æ ¡éªŒå­˜åœ¨
        MesWmReturnSalesDO returnSales = validateReturnSalesExists(id);
        MesWmReturnSalesDO returnSales = returnSalesMapper.selectByIdForUpdate(id);
        if (returnSales == null) throw exception(WM_RETURN_SALES_NOT_EXISTS);
        // å·²å®Œæˆå’Œå·²å–消不允许取消
        if (ObjectUtils.equalsAny(returnSales.getStatus(),
                MesWmReturnSalesStatusEnum.FINISHED.getStatus(),
@@ -243,6 +252,9 @@
    public Boolean checkReturnSalesQuantity(Long id) {
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(id);
        for (MesWmReturnSalesLineDO line : lines) {
            if (line.getNcrDispositionId() != null && Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())) {
                continue;
            }
            List<MesWmReturnSalesDetailDO> details = returnSalesDetailService.getReturnSalesDetailListByLineId(line.getId());
            BigDecimal totalDetailQty = CollectionUtils.getSumValue(details,
                    MesWmReturnSalesDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO);
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcArrivalDispositionServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseReturnDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseReturnMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseConcessionMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmArrivalNoticeStatusEnum;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesQcArrivalDispositionServiceTest {
    @Test void partialReturnEnablesOnlyQualifiedQuantityAndFullReturnFinishes() {
        var service = new MesQcArrivalDispositionService();
        var notices = mock(MesWmArrivalNoticeMapper.class);
        var lines = mock(MesWmArrivalNoticeLineMapper.class);
        var returns = mock(MesQcPurchaseReturnMapper.class);
        ReflectionTestUtils.setField(service, "arrivalNoticeMapper", notices);
        ReflectionTestUtils.setField(service, "lineMapper", lines);
        ReflectionTestUtils.setField(service, "returnMapper", returns);
        ReflectionTestUtils.setField(service, "concessionMapper", mock(MesQcPurchaseConcessionMapper.class));
        when(notices.selectOne(any(Wrapper.class))).thenReturn(new MesWmArrivalNoticeDO().setId(1L)
                .setStatus(MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus()));
        var line = new MesWmArrivalNoticeLineDO().setId(2L).setIqcId(3L).setIqcCheckFlag(true)
                .setIqcCheckResult(2).setArrivalQuantity(BigDecimal.TEN).setQualifiedQuantity(BigDecimal.valueOf(7));
        when(lines.selectListByNoticeId(1L)).thenReturn(List.of(line));
        service.refresh(1L);
        verify(notices, never()).updateById(any(MesWmArrivalNoticeDO.class));
        when(returns.selectConfirmedByIqcId(3L)).thenReturn(new MesQcPurchaseReturnDO().setNoticeLineId(2L));
        service.refresh(1L);
        var changed = ArgumentCaptor.forClass(MesWmArrivalNoticeDO.class);
        verify(notices).updateById(changed.capture());
        assertEquals(MesWmArrivalNoticeStatusEnum.PENDING_RECEIPT.getStatus(), changed.getValue().getStatus());
        line.setQualifiedQuantity(BigDecimal.ZERO);
        service.refresh(1L);
        verify(notices, times(2)).updateById(changed.capture());
        assertEquals(MesWmArrivalNoticeStatusEnum.FINISHED.getStatus(), changed.getValue().getStatus());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrGuardTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcNcrMapper;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrStatusEnum;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MesQcNcrGuardTest {
    @Mock private MesQcNcrMapper mapper;
    @Mock private MdmItemApi mdmItemApi;
    @InjectMocks private MesQcNcrServiceImpl service;
    private MesQcNcrDO sourceNcr() {
        return new MesQcNcrDO().setId(1L).setCode("NCR-1").setQcId(2L)
                .setItemId(3L).setUnqualifiedQuantity(new BigDecimal("10"))
                .setStatus(MesNcrStatusEnum.PENDING_REVIEW.getStatus());
    }
    private MesQcNcrSaveReqVO request(Long itemId, String quantity) {
        var req = new MesQcNcrSaveReqVO();
        req.setId(1L);
        req.setCode("NCR-1");
        req.setItemId(itemId);
        req.setUnqualifiedQuantity(new BigDecimal(quantity));
        return req;
    }
    @Test
    void rejectsChangingMaterialOrQuantityOfSourceInspection() {
        when(mapper.selectByIdForUpdate(1L)).thenReturn(sourceNcr());
        assertThrows(ServiceException.class, () -> service.updateNcr(request(4L, "10")));
        assertThrows(ServiceException.class, () -> service.updateNcr(request(3L, "11")));
        verify(mapper, times(2)).selectByIdForUpdate(1L);
        verify(mapper, never()).updateById(any(MesQcNcrDO.class));
    }
    @Test
    void rejectsDeletingSourceInspectionNcr() {
        when(mapper.selectByIdForUpdate(1L)).thenReturn(sourceNcr());
        assertThrows(ServiceException.class, () -> service.deleteNcr(1L));
        verify(mapper, never()).deleteById(any(Long.class));
    }
    @Test
    void acceptsEquivalentQuantityWithDifferentDecimalScale() {
        when(mapper.selectByIdForUpdate(1L)).thenReturn(sourceNcr());
        when(mdmItemApi.getItem(3L)).thenReturn(CommonResult.success(null));
        service.updateNcr(request(3L, "10.00"));
        verify(mapper).updateById(any(MesQcNcrDO.class));
    }
    @Test
    void repeatedDispositionChecksLockedCurrentStatusBeforeCreatingDocuments() {
        var disposed = sourceNcr().setStatus(MesNcrStatusEnum.DISPOSED.getStatus());
        when(mapper.selectByIdForUpdate(1L)).thenReturn(disposed);
        var req = new MesQcNcrDisposeReqVO();
        req.setId(1L);
        req.setDisposition(2);
        assertThrows(ServiceException.class, () -> service.disposeNcr(req));
        verify(mapper).selectByIdForUpdate(1L);
        verify(mapper, never()).updateById(any(MesQcNcrDO.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcConcessionServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesLineService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesDetailService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.productsales.vo.detail.MesWmProductSalesDetailSaveReqVO;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesQcNcrOqcConcessionServiceTest {
    @Test void releaseAndCloseRequireMatchingOriginalLineAndShipment() {
        var service = new MesQcNcrOqcConcessionService();
        var oqcs = mock(MesQcOqcMapper.class);
        var sales = mock(MesWmProductSalesMapper.class);
        var lines = mock(MesWmProductSalesLineMapper.class);
        var flows = mock(MesWmTransactionMapper.class);
        var lineService = mock(MesWmProductSalesLineService.class);
        var details = mock(MesWmProductSalesDetailService.class);
        var salesService = mock(MesWmProductSalesService.class);
        var stockService = mock(MesWmMaterialStockService.class);
        ReflectionTestUtils.setField(service, "oqcMapper", oqcs);
        ReflectionTestUtils.setField(service, "salesMapper", sales);
        ReflectionTestUtils.setField(service, "lineMapper", lines);
        ReflectionTestUtils.setField(service, "transactionMapper", flows);
        ReflectionTestUtils.setField(service, "lineService", lineService);
        ReflectionTestUtils.setField(service, "detailService", details);
        ReflectionTestUtils.setField(service, "salesService", salesService);
        ReflectionTestUtils.setField(service, "stockService", stockService);
        var ncr = new MesQcNcrDO().setQcId(1L).setItemId(2L).setUnqualifiedQuantity(BigDecimal.valueOf(3));
        var qc = new MesQcOqcDO().setId(1L).setSourceDocType(MesBizTypeConstants.WM_PRODUCT_SALES)
                .setSourceDocId(4L).setSourceLineId(5L).setItemId(2L).setBatchCode("B1")
                .setStatus(MesQcStatusEnum.FINISHED.getStatus()).setCheckResult(2)
                .setCheckQuantity(BigDecimal.TEN).setQualifiedQuantity(BigDecimal.valueOf(7)).setUnqualifiedQuantity(BigDecimal.valueOf(3));
        var sale = new MesWmProductSalesDO().setId(4L).setStatus(MesWmProductSalesStatusEnum.CONFIRMED.getStatus());
        var line = new MesWmProductSalesLineDO().setId(5L).setSalesId(4L).setItemId(2L).setOqcId(1L)
                .setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus()).setQuantity(BigDecimal.TEN);
        when(oqcs.selectById(1L)).thenReturn(qc);
        when(sales.selectOne(any(Wrapper.class))).thenReturn(sale);
        when(lines.selectOne(any(Wrapper.class))).thenReturn(line);
        when(stockService.getMaterialStock(8L)).thenReturn(new MesWmMaterialStockDO().setId(8L).setItemId(2L)
                .setBatchCode("B1").setWarehouseId(11L).setLocationId(12L).setAreaId(13L));
        when(details.getProductSalesDetailListByLineId(5L)).thenReturn(List.of());
        when(lineService.getProductSalesLineListBySalesId(4L)).thenReturn(List.of(line));
        when(salesService.checkProductSalesQuantity(4L)).thenReturn(true);
        var req = new MesQcNcrDisposeReqVO(); req.setMaterialStockId(8L);
        service.release(ncr, req);
        verify(lineService).updateProductSalesLineWhenOqcFinish(5L, 1L, 1);
        verify(details).createProductSalesDetail(any(MesWmProductSalesDetailSaveReqVO.class));
        verify(salesService).stockProductSales(4L);
        verify(salesService).finishProductSales(4L);
        assertThrows(ServiceException.class, () -> service.validateBeforeClose(ncr));
        sale.setStatus(MesWmProductSalesStatusEnum.FINISHED.getStatus());
        when(flows.selectList(any(Wrapper.class))).thenReturn(List.of(new MesWmTransactionDO()
                .setItemId(2L).setBatchCode("B2").setQuantity(BigDecimal.TEN.negate())));
        assertThrows(ServiceException.class, () -> service.validateBeforeClose(ncr));
        when(flows.selectList(any(Wrapper.class))).thenReturn(List.of(new MesWmTransactionDO()
                .setItemId(2L).setBatchCode("B1").setQuantity(BigDecimal.TEN.negate())));
        assertDoesNotThrow(() -> service.validateBeforeClose(ncr));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReproductionServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.pro.workorder.vo.process.MesProWorkOrderProcessSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderProcessDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdItemMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderBomMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.batch.MesWmBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MesQcNcrOqcReproductionServiceTest {
    @InjectMocks MesQcNcrOqcReproductionService service;
    @Mock MesQcOqcMapper oqcMapper;
    @Mock MesWmProductSalesLineMapper lineMapper;
    @Mock MesWmProductSalesMapper salesMapper;
    @Mock MesWmProductProduceLineMapper produceLineMapper;
    @Mock MesQcNcrScrapService sourceService;
    @Mock MesWmBatchMapper batchMapper;
    @Mock MesProWorkOrderMapper orderMapper;
    @Mock MesProWorkOrderBomMapper bomMapper;
    @Mock MesMdItemMapper itemMapper;
    @Mock MesProWorkOrderService orderService;
    @Mock MesProWorkOrderProcessService processService;
    @Mock MesProTaskService taskService;
    @Mock MesMdAutoCodeRecordService autoCodeService;
    private MesQcNcrDO ncr() { return new MesQcNcrDO().setId(1L).setCode("NCR1").setQcType(MesQcTypeEnum.OQC.getType()).setQcId(2L).setItemId(3L).setUnqualifiedQuantity(BigDecimal.ONE); }
    private MesQcOqcDO qc() { return new MesQcOqcDO().setId(2L).setSourceDocType(MesBizTypeConstants.WM_PRODUCT_SALES)
            .setSourceDocId(4L).setSourceLineId(5L).setStatus(MesQcStatusEnum.FINISHED.getStatus()).setItemId(3L)
            .setClientId(8L).setBatchCode("B1").setOutQuantity(BigDecimal.TEN).setUnqualifiedQuantity(BigDecimal.ONE); }
    private MesWmProductSalesLineDO line() { return new MesWmProductSalesLineDO().setId(5L).setSalesId(4L).setOqcId(2L).setItemId(3L)
            .setBatchId(6L).setBatchCode("B1").setQuantity(BigDecimal.TEN).setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus()); }
    @Test void refusesClientMismatchBeforeCreatingOrder() {
        when(oqcMapper.selectById(2L)).thenReturn(qc());
        when(lineMapper.selectById(5L)).thenReturn(line());
        when(salesMapper.selectById(4L)).thenReturn(new MesWmProductSalesDO().setId(4L).setClientId(9L));
        assertThrows(ServiceException.class, () -> service.reproduce(ncr(), 7));
        verify(orderService, never()).createWorkOrder(any());
    }
    @Test void ipqcReproductionRequiresClaimedSourceAndCreatesTasks() {
        var item = ncr().setQcType(MesQcTypeEnum.IPQC.getType());
        var source = new MesQcNcrScrapService.IpqcSource(10L, "OLD", 12L, 6L, "B1");
        when(sourceService.validateIpqcSource(item)).thenReturn(source);
        var line = new MesWmProductProduceLineDO().setId(12L).setNcrDispositionId(99L);
        when(produceLineMapper.selectById(12L)).thenReturn(line);
        assertThrows(ServiceException.class, () -> service.reproduceIpqc(item, 7, source));
        verify(orderService, never()).createWorkOrder(any());
        line.setNcrDispositionId(1L);
        when(orderMapper.selectById(10L)).thenReturn(new MesProWorkOrderDO().setId(10L).setCode("OLD")
                .setProductId(11L).setRouteId(40L));
        when(itemMapper.selectById(11L)).thenReturn(new MesMdItemDO().setId(11L).setMdmItemId(3L));
        when(processService.getWorkOrderProcessListByWorkOrderId(10L)).thenReturn(List.of(
                new MesProWorkOrderProcessDO().setProcessId(21L).setSort(1)));
        when(bomMapper.selectListByWorkOrderId(10L)).thenReturn(List.of());
        when(autoCodeService.generateAutoCode(anyString())).thenReturn("NEW");
        when(orderService.createWorkOrder(any())).thenReturn(30L);
        when(taskService.autoGenerateTasks(any())).thenReturn(List.of(50L));
        assertEquals(30L, service.reproduceIpqc(item, 7, source).getDocId());
        verify(taskService).autoGenerateTasks(argThat(r -> r.getWorkOrderId().equals(30L)));
    }
    @Test void copiesOrderProcessThenConfirms() {
        when(oqcMapper.selectById(2L)).thenReturn(qc());
        when(lineMapper.selectById(5L)).thenReturn(line());
        when(salesMapper.selectById(4L)).thenReturn(new MesWmProductSalesDO().setId(4L).setClientId(8L));
        when(batchMapper.selectById(6L)).thenReturn(new MesWmBatchDO().setId(6L).setCode("B1").setItemId(3L).setClientId(8L).setWorkOrderId(10L));
        when(orderMapper.selectById(10L)).thenReturn(new MesProWorkOrderDO().setId(10L).setCode("OLD").setProductId(11L).setClientId(8L).setRouteId(40L));
        when(itemMapper.selectById(11L)).thenReturn(new MesMdItemDO().setId(11L).setMdmItemId(3L));
        when(processService.getWorkOrderProcessListByWorkOrderId(10L)).thenReturn(List.of(new MesProWorkOrderProcessDO().setId(20L)
                .setProcessId(21L).setSort(1).setOutputItemId("3,4")));
        when(bomMapper.selectListByWorkOrderId(10L)).thenReturn(List.of(new MesProWorkOrderBomDO()
                .setProcessId(21L).setItemId(22L).setQuantity(new BigDecimal("1.25"))));
        when(autoCodeService.generateAutoCode(anyString())).thenReturn("NEW");
        when(orderService.createWorkOrder(any())).thenReturn(30L);
        when(taskService.autoGenerateTasks(any())).thenReturn(List.of(50L));
        var result = service.reproduce(ncr(), 7);
        assertEquals(30L, result.getDocId());
        verify(processService).createWorkOrderProcess(argThat(p -> p.getId() == null && p.getWorkOrderId().equals(30L)
                && p.getProcessId().equals(21L) && p.getOutputItemIds().equals(List.of(3L, 4L))));
        verify(bomMapper).deleteByWorkOrderId(30L);
        verify(bomMapper).insert(argThat((MesProWorkOrderBomDO b) -> b.getWorkOrderId().equals(30L)
                && b.getProcessId().equals(21L) && b.getQuantity().compareTo(new BigDecimal("1.25")) == 0));
        var inOrder = inOrder(processService, orderService);
        inOrder.verify(processService).getWorkOrderProcessListByWorkOrderId(10L);
        inOrder.verify(orderService).createWorkOrder(any());
        inOrder.verify(processService).createWorkOrderProcess(any(MesProWorkOrderProcessSaveReqVO.class));
        inOrder.verify(orderService).confirmWorkOrder(30L);
        verify(taskService).autoGenerateTasks(argThat(r -> r.getWorkOrderId().equals(30L) && r.getRouteId().equals(40L)));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrOqcReworkServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,250 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.miscissue.vo.MesWmMiscIssueSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.miscissue.vo.line.MesWmMiscIssueLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.miscissue.MesWmMiscIssueDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
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.dal.mysql.wm.miscissue.MesWmMiscIssueMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrDispositionEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmMiscIssueStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmMiscIssueTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.autocode.MesMdAutoCodeRecordService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
import cn.iocoder.yudao.module.mes.service.wm.miscissue.MesWmMiscIssueLineService;
import cn.iocoder.yudao.module.mes.service.wm.miscissue.MesWmMiscIssueService;
import cn.iocoder.yudao.module.mes.service.wm.productsales.MesWmProductSalesService;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesQcNcrOqcReworkServiceTest {
    private static final long NCR_ID = 100L;
    private static final long QC_ID = 1L;
    private static final long SALES_ID = 4L;
    private static final long SALES_LINE_ID = 5L;
    private static final long BATCH_ID = 6L;
    private static final long ORDER_ID = 7L;
    private static final long STOCK_ID = 9L;
    private MesQcNcrOqcReworkService newService() {
        var service = new MesQcNcrOqcReworkService();
        ReflectionTestUtils.setField(service, "batchMapper", mock(MesWmBatchMapper.class));
        ReflectionTestUtils.setField(service, "orderMapper", mock(MesProWorkOrderMapper.class));
        ReflectionTestUtils.setField(service, "oqcMapper", mock(MesQcOqcMapper.class));
        ReflectionTestUtils.setField(service, "stockMapper", mock(MesWmMaterialStockMapper.class));
        ReflectionTestUtils.setField(service, "miscIssueMapper", mock(MesWmMiscIssueMapper.class));
        ReflectionTestUtils.setField(service, "transactionMapper", mock(MesWmTransactionMapper.class));
        ReflectionTestUtils.setField(service, "salesMapper", mock(MesWmProductSalesMapper.class));
        ReflectionTestUtils.setField(service, "stockService", mock(MesWmMaterialStockService.class));
        ReflectionTestUtils.setField(service, "salesService", mock(MesWmProductSalesService.class));
        ReflectionTestUtils.setField(service, "miscIssueService", mock(MesWmMiscIssueService.class));
        ReflectionTestUtils.setField(service, "miscIssueLineService", mock(MesWmMiscIssueLineService.class));
        ReflectionTestUtils.setField(service, "autoCodeRecordService", mock(MesMdAutoCodeRecordService.class));
        ReflectionTestUtils.setField(service, "itemService", mock(cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService.class));
        ReflectionTestUtils.setField(service, "reproductionService", mock(MesQcNcrOqcReproductionService.class));
        ReflectionTestUtils.setField(service, "warehouseService", mock(cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService.class));
        ReflectionTestUtils.setField(service, "warehouseLocationService", mock(cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService.class));
        ReflectionTestUtils.setField(service, "warehouseAreaService", mock(cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService.class));
        return service;
    }
    private static <T> T field(MesQcNcrOqcReworkService service, String name, Class<T> type) {
        return type.cast(ReflectionTestUtils.getField(service, name));
    }
    private MesQcNcrDO ncr() {
        return new MesQcNcrDO().setId(NCR_ID).setCode("NCR1").setQcId(QC_ID)
                .setItemId(2L).setUnqualifiedQuantity(BigDecimal.valueOf(3));
    }
    private MesQcNcrOqcSourceClaim.OqcClaimedSource source() {
        var qc = new MesQcOqcDO().setId(QC_ID).setSourceDocId(SALES_ID).setSourceLineId(SALES_LINE_ID);
        var sales = new MesWmProductSalesDO().setId(SALES_ID).setCode("SALES1").setClientId(40L)
                .setStatus(MesWmProductSalesStatusEnum.CONFIRMED.getStatus());
        var line = new MesWmProductSalesLineDO().setId(SALES_LINE_ID).setSalesId(SALES_ID)
                .setOqcId(QC_ID).setItemId(2L).setBatchId(BATCH_ID).setBatchCode("B1")
                .setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus()).setQuantity(BigDecimal.TEN);
        return new MesQcNcrOqcSourceClaim.OqcClaimedSource(qc, sales, line);
    }
    private static MesWmMaterialStockDO stock(BigDecimal quantity) {
        return new MesWmMaterialStockDO().setId(STOCK_ID).setItemId(2L).setBatchId(BATCH_ID).setBatchCode("B1")
                .setWarehouseId(11L).setLocationId(12L).setAreaId(13L).setQuantity(quantity)
                .setFrozenQuantity(BigDecimal.ZERO).setReservedQuantity(BigDecimal.ZERO);
    }
    private void stubBatchAndOrder(MesQcNcrOqcReworkService service) {
        when(field(service, "batchMapper", MesWmBatchMapper.class).selectById(BATCH_ID))
                .thenReturn(new MesWmBatchDO().setId(BATCH_ID).setCode("B1").setItemId(2L)
                        .setClientId(40L).setWorkOrderId(ORDER_ID));
        when(field(service, "orderMapper", MesProWorkOrderMapper.class).selectById(ORDER_ID))
                .thenReturn(new MesProWorkOrderDO().setId(ORDER_ID).setProductId(8L).setClientId(40L));
    }
    @Test
    void disposeDeductsSelectedStockCancelsSalesAndCreatesReworkOrder() {
        var service = newService();
        var source = source();
        stubBatchAndOrder(service);
        var salesService = field(service, "salesService", MesWmProductSalesService.class);
        var stockService = field(service, "stockService", MesWmMaterialStockService.class);
        var stockMapper = field(service, "stockMapper", MesWmMaterialStockMapper.class);
        var miscIssueService = field(service, "miscIssueService", MesWmMiscIssueService.class);
        var miscLineService = field(service, "miscIssueLineService", MesWmMiscIssueLineService.class);
        var autoCode = field(service, "autoCodeRecordService", MesMdAutoCodeRecordService.class);
        var reproduction = field(service, "reproductionService", MesQcNcrOqcReproductionService.class);
        when(stockService.getMaterialStock(STOCK_ID)).thenReturn(stock(BigDecimal.TEN));
        when(stockMapper.lockById(STOCK_ID)).thenReturn(stock(BigDecimal.TEN));
        when(autoCode.generateAutoCode(anyString())).thenReturn("MI1");
        when(miscIssueService.createMiscIssue(any(MesWmMiscIssueSaveReqVO.class))).thenReturn(20L);
        var result = new MesQcNcrDisposeRespVO();
        result.setDocCode("WO1");
        when(reproduction.createReworkFromOriginal(any(), anyInt(), any(), any(), any(), any())).thenReturn(result);
        var req = new MesQcNcrDisposeReqVO();
        req.setId(NCR_ID);
        req.setDisposition(MesNcrDispositionEnum.REWORK.getType());
        req.setMaterialStockId(STOCK_ID);
        var resp = service.dispose(ncr(), source, req);
        var issueCaptor = ArgumentCaptor.forClass(MesWmMiscIssueSaveReqVO.class);
        verify(miscIssueService).createMiscIssue(issueCaptor.capture());
        assertEquals(MesWmMiscIssueTypeEnum.REWORK.getType(), issueCaptor.getValue().getType());
        assertEquals("QC_NCR", issueCaptor.getValue().getSourceDocType());
        assertEquals(NCR_ID, issueCaptor.getValue().getSourceDocId().longValue());
        var lineCaptor = ArgumentCaptor.forClass(MesWmMiscIssueLineSaveReqVO.class);
        verify(miscLineService).createMiscIssueLine(lineCaptor.capture());
        assertEquals(STOCK_ID, lineCaptor.getValue().getMaterialStockId().longValue());
        assertEquals(BATCH_ID, lineCaptor.getValue().getBatchId().longValue());
        assertEquals(0, BigDecimal.valueOf(3).compareTo(lineCaptor.getValue().getQuantity()));
        InOrder order = inOrder(stockMapper, salesService, miscIssueService, reproduction);
        order.verify(stockMapper).lockById(STOCK_ID);
        order.verify(salesService).cancelProductSales(SALES_ID);
        order.verify(miscIssueService).finishMiscIssue(20L);
        order.verify(reproduction).createReworkFromOriginal(any(), anyInt(), any(), any(), any(), any());
        assertNotNull(resp.getMessage());
        assertTrue(resp.getMessage().contains("MI1"));
        assertTrue(resp.getMessage().contains("SALES1"));
        assertTrue(resp.getMessage().contains("WO1"));
    }
    @Test
    void disposeRejectsWhenStockAvailableIsInsufficient() {
        var service = newService();
        stubBatchAndOrder(service);
        var stockService = field(service, "stockService", MesWmMaterialStockService.class);
        var stockMapper = field(service, "stockMapper", MesWmMaterialStockMapper.class);
        when(stockService.getMaterialStock(STOCK_ID)).thenReturn(stock(BigDecimal.ONE));
        when(stockMapper.lockById(STOCK_ID)).thenReturn(stock(BigDecimal.ONE));
        var req = new MesQcNcrDisposeReqVO();
        req.setDisposition(MesNcrDispositionEnum.REWORK.getType());
        req.setMaterialStockId(STOCK_ID);
        var ex = assertThrows(ServiceException.class, () -> service.dispose(ncr(), source(), req));
        assertTrue(ex.getMessage().contains("可用量"));
        verify(field(service, "salesService", MesWmProductSalesService.class), never()).cancelProductSales(any());
    }
    @Test
    void disposeRejectsAmbiguousStockWhenNoStockSelected() {
        var service = newService();
        stubBatchAndOrder(service);
        var stockMapper = field(service, "stockMapper", MesWmMaterialStockMapper.class);
        when(stockMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
                stock(BigDecimal.TEN).setId(9L).setWarehouseId(11L),
                stock(BigDecimal.TEN).setId(10L).setWarehouseId(21L)));
        var req = new MesQcNcrDisposeReqVO();
        req.setDisposition(MesNcrDispositionEnum.REPAIR.getType());
        var ex = assertThrows(ServiceException.class, () -> service.dispose(ncr(), source(), req));
        assertTrue(ex.getMessage().contains("返工库存"));
        verify(field(service, "salesService", MesWmProductSalesService.class), never()).cancelProductSales(any());
    }
    @Test
    void validateBeforeCloseRequiresFinishedOrderCancelledSalesAndMatchingIssue() {
        var service = newService();
        var ncr = ncr();
        var orders = field(service, "orderMapper", MesProWorkOrderMapper.class);
        var oqcs = field(service, "oqcMapper", MesQcOqcMapper.class);
        var salesMapper = field(service, "salesMapper", MesWmProductSalesMapper.class);
        var issues = field(service, "miscIssueMapper", MesWmMiscIssueMapper.class);
        var flows = field(service, "transactionMapper", MesWmTransactionMapper.class);
        // 1. æ‰¾ä¸åˆ°è¿”工工单
        when(orders.selectList(any(Wrapper.class))).thenReturn(List.of());
        assertThrows(ServiceException.class, () -> service.validateBeforeClose(ncr));
        // 2. å·¥å•未完成
        var order = new MesProWorkOrderDO().setId(ORDER_ID).setCode("WO1");
        when(orders.selectList(any(Wrapper.class))).thenReturn(List.of(order));
        assertThrows(ServiceException.class, () -> service.validateBeforeClose(ncr));
        order.setStatus(MesProWorkOrderStatusEnum.FINISHED.getStatus())
                .setFinishDate(java.time.LocalDateTime.now());
        // 3. åŽŸé”€å”®å‡ºåº“å•æœªå–æ¶ˆ
        when(oqcs.selectById(QC_ID)).thenReturn(new MesQcOqcDO().setId(QC_ID).setSourceDocId(SALES_ID));
        var sales = new MesWmProductSalesDO().setId(SALES_ID).setCode("SALES1")
                .setStatus(MesWmProductSalesStatusEnum.CONFIRMED.getStatus());
        when(salesMapper.selectById(SALES_ID)).thenReturn(sales);
        assertThrows(ServiceException.class, () -> service.validateBeforeClose(ncr));
        sales.setStatus(MesWmProductSalesStatusEnum.CANCELED.getStatus());
        // 4. è¿”工领用出库单缺失
        when(issues.selectList(any(Wrapper.class))).thenReturn(List.of());
        assertThrows(ServiceException.class, () -> service.validateBeforeClose(ncr));
        var issue = new MesWmMiscIssueDO().setId(20L).setCode("MI1")
                .setStatus(MesWmMiscIssueStatusEnum.FINISHED.getStatus());
        when(issues.selectList(any(Wrapper.class))).thenReturn(List.of(issue));
        // 5. å‡ºåº“流水数量不符
        when(flows.selectList(any(Wrapper.class))).thenReturn(List.of(new MesWmTransactionDO()
                .setItemId(2L).setType(MesWmTransactionTypeEnum.OUT.getType())
                .setQuantity(BigDecimal.valueOf(-2))));
        assertThrows(ServiceException.class, () -> service.validateBeforeClose(ncr));
        // 6. å…¨éƒ¨æ»¡è¶³
        when(flows.selectList(any(Wrapper.class))).thenReturn(List.of(new MesWmTransactionDO()
                .setItemId(2L).setType(MesWmTransactionTypeEnum.OUT.getType())
                .setQuantity(BigDecimal.valueOf(-3))));
        assertDoesNotThrow(() -> service.validateBeforeClose(ncr));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrPurchaseSourceValidatorTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.iqc.MesQcIqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmArrivalNoticeStatusEnum;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesQcNcrPurchaseSourceValidatorTest {
    @Test void verifiesAllSourceLinksAndQuantities() {
        var validator = new MesQcNcrPurchaseSourceValidator();
        var iqcs = mock(MesQcIqcMapper.class);
        var notices = mock(MesWmArrivalNoticeMapper.class);
        var lines = mock(MesWmArrivalNoticeLineMapper.class);
        ReflectionTestUtils.setField(validator, "iqcMapper", iqcs);
        ReflectionTestUtils.setField(validator, "arrivalNoticeMapper", notices);
        ReflectionTestUtils.setField(validator, "lineMapper", lines);
        var ncr = new MesQcNcrDO().setQcType(MesQcTypeEnum.IQC.getType()).setQcId(1L)
                .setItemId(4L).setUnqualifiedQuantity(BigDecimal.TEN);
        var iqc = new MesQcIqcDO().setId(1L).setSourceDocType(MesBizTypeConstants.WM_ARRIVAL_NOTICE)
                .setSourceDocId(2L).setSourceLineId(3L).setItemId(4L)
                .setUnqualifiedQuantity(BigDecimal.TEN).setCheckQuantity(BigDecimal.valueOf(100))
                .setStatus(MesQcStatusEnum.FINISHED.getStatus());
        var notice = new MesWmArrivalNoticeDO().setId(2L).setStatus(MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus());
        var line = new MesWmArrivalNoticeLineDO().setId(3L).setNoticeId(2L).setIqcId(1L)
                .setItemId(4L).setArrivalQuantity(BigDecimal.valueOf(100));
        when(iqcs.selectOne(any(Wrapper.class))).thenReturn(iqc);
        when(notices.selectOne(any(Wrapper.class))).thenReturn(notice);
        when(lines.selectOne(any(Wrapper.class))).thenReturn(line);
        assertDoesNotThrow(() -> validator.validate(ncr));
        line.setIqcId(9L);
        assertThrows(ServiceException.class, () -> validator.validate(ncr));
        line.setIqcId(1L).setArrivalQuantity(BigDecimal.valueOf(101));
        assertThrows(ServiceException.class, () -> validator.validate(ncr));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrReinspectionValidatorTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdItemMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProWorkOrderStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.transaction.MesWmTransactionDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcNcrMapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesQcNcrReinspectionValidatorTest {
    @Test void onlyExplicitPostCompletionWholeLotInspectionCanClose() {
        var validator = new MesQcNcrReinspectionValidator();
        var orders = mock(MesProWorkOrderMapper.class);
        var items = mock(MesMdItemMapper.class);
        var inspections = mock(MesQcIpqcMapper.class);
        var lines = mock(MesWmProductProduceLineMapper.class);
        var produces = mock(MesWmProductProduceMapper.class);
        var transactions = mock(MesWmTransactionMapper.class);
        var ncrs = mock(MesQcNcrMapper.class);
        ReflectionTestUtils.setField(validator, "workOrderMapper", orders);
        ReflectionTestUtils.setField(validator, "itemMapper", items);
        ReflectionTestUtils.setField(validator, "ipqcMapper", inspections);
        ReflectionTestUtils.setField(validator, "produceLineMapper", lines);
        ReflectionTestUtils.setField(validator, "produceMapper", produces);
        ReflectionTestUtils.setField(validator, "transactionMapper", transactions);
        ReflectionTestUtils.setField(validator, "ncrMapper", ncrs);
        var finishedAt = LocalDateTime.of(2026, 1, 1, 10, 0);
        var order = new MesProWorkOrderDO().setId(1L).setProductId(2L)
                .setStatus(MesProWorkOrderStatusEnum.FINISHED.getStatus()).setFinishDate(finishedAt);
        var product = new MesMdItemDO().setId(2L).setMdmItemId(3L);
        var ncr = new MesQcNcrDO().setCode("NCR1").setQcType(MesQcTypeEnum.IPQC.getType()).setItemId(3L).setUnqualifiedQuantity(BigDecimal.TEN)
                .setHandleDate(finishedAt);
        var qc = new MesQcIpqcDO().setId(4L).setWorkOrderId(1L).setItemId(2L)
                .setSourceDocType(MesBizTypeConstants.PRO_FEEDBACK).setSourceDocId(7L).setSourceLineId(8L)
                .setInspectDate(finishedAt.plusMinutes(1)).setStatus(MesQcStatusEnum.FINISHED.getStatus())
                .setCheckResult(1).setCheckQuantity(BigDecimal.TEN).setQualifiedQuantity(BigDecimal.TEN)
                .setUnqualifiedQuantity(BigDecimal.ZERO);
        qc.setCreateTime(finishedAt.plusMinutes(1));
        when(orders.selectList(any(Wrapper.class))).thenReturn(List.of(order));
        when(items.selectById(2L)).thenReturn(product);
        when(inspections.selectById(4L)).thenReturn(qc);
        var line = new MesWmProductProduceLineDO().setId(8L).setProduceId(9L)
                .setFeedbackId(7L).setItemId(2L).setQuantity(BigDecimal.TEN).setQualityStatus(MesWmQualityStatusEnum.PASS.getStatus());
        when(lines.selectById(8L)).thenReturn(line);
        when(produces.selectById(9L)).thenReturn(new MesWmProductProduceDO().setId(9L).setWorkOrderId(1L));
        when(transactions.selectList(any(Wrapper.class))).thenReturn(List.of(new MesWmTransactionDO().setQuantity(BigDecimal.TEN)));
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, null));
        assertDoesNotThrow(() -> validator.validate(ncr, 4L));
        qc.setCheckResult(2).setQualifiedQuantity(BigDecimal.valueOf(6)).setUnqualifiedQuantity(BigDecimal.valueOf(4));
        line.setQuantity(BigDecimal.valueOf(6));
        when(transactions.selectList(any(Wrapper.class))).thenReturn(List.of(new MesWmTransactionDO().setQuantity(BigDecimal.valueOf(6))));
        when(ncrs.selectList(any(Wrapper.class))).thenReturn(List.of());
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        when(ncrs.selectList(any(Wrapper.class))).thenReturn(List.of(new MesQcNcrDO().setItemId(3L).setUnqualifiedQuantity(BigDecimal.valueOf(4))));
        assertDoesNotThrow(() -> validator.validate(ncr, 4L));
        qc.setQualifiedQuantity(BigDecimal.ZERO).setUnqualifiedQuantity(BigDecimal.TEN);
        line.setQuantity(BigDecimal.ZERO);
        when(transactions.selectList(any(Wrapper.class))).thenReturn(List.of());
        when(ncrs.selectList(any(Wrapper.class))).thenReturn(List.of(new MesQcNcrDO().setItemId(3L).setUnqualifiedQuantity(BigDecimal.TEN)));
        assertDoesNotThrow(() -> validator.validate(ncr, 4L));
        qc.setCheckResult(1).setQualifiedQuantity(BigDecimal.TEN).setUnqualifiedQuantity(BigDecimal.ZERO);
        line.setQuantity(BigDecimal.TEN);
        when(transactions.selectList(any(Wrapper.class))).thenReturn(List.of());
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        when(transactions.selectList(any(Wrapper.class))).thenReturn(List.of(new MesWmTransactionDO().setQuantity(BigDecimal.TEN)));
        qc.setInspectDate(finishedAt.minusMinutes(1));
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        qc.setInspectDate(finishedAt.plusMinutes(1)).setQualifiedQuantity(BigDecimal.ONE);
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        qc.setQualifiedQuantity(BigDecimal.TEN).setCheckResult(2).setUnqualifiedQuantity(BigDecimal.ONE);
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        qc.setCheckResult(1).setUnqualifiedQuantity(BigDecimal.ZERO);
        qc.setQualifiedQuantity(BigDecimal.TEN).setWorkOrderId(5L);
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        qc.setWorkOrderId(1L);
        // å‡ºè´§è¿”工同样走报工产出复检(严格分支),不再放行独立过程检验单
        ncr.setQcType(MesQcTypeEnum.OQC.getType());
        qc.setSourceDocType(null).setSourceDocId(null).setSourceLineId(null);
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        qc.setSourceDocType(MesBizTypeConstants.PRO_FEEDBACK).setSourceDocId(7L).setSourceLineId(8L);
        assertDoesNotThrow(() -> validator.validate(ncr, 4L));
        ncr.setQcType(MesQcTypeEnum.IPQC.getType());
        qc.setSourceDocType(MesBizTypeConstants.PRO_FEEDBACK).setSourceDocId(7L).setSourceLineId(8L);
        order.setFinishDate(null);
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
        order.setFinishDate(finishedAt);
        when(orders.selectList(any(Wrapper.class))).thenReturn(List.of(order, order));
        assertThrows(RuntimeException.class, () -> validator.validate(ncr, 4L));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrScrapServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.rqc.MesQcRqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productproduce.MesWmProductProduceDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.rqc.MesQcRqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.returnsales.MesWmReturnSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.scrap.MesWmScrapReceiptMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.transaction.MesWmTransactionMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
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.MesWmWarehouseService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MesQcNcrScrapServiceTest {
    @BeforeAll static void initializeTableInfo() {
        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
                MesWmProductProduceLineDO.class);
    }
    @InjectMocks MesQcNcrScrapService service;
    @Mock MesQcRqcMapper rqc;
    @Mock MesQcIpqcMapper ipqc;
    @Mock MesProWorkOrderMapper orders;
    @Mock MesWmProductProduceLineMapper outputs;
    @Mock MesWmProductProduceMapper produces;
    @Mock MesWmReturnSalesLineMapper lines;
    @Mock MesWmScrapReceiptMapper receipts;
    @Mock MesWmTransactionMapper transactions;
    @Mock MesWmTransactionService transactionService;
    @Mock MesWmWarehouseService warehouse;
    @Mock MesWmWarehouseAreaService area;
    private MesQcNcrDO ncr() { return new MesQcNcrDO().setId(1L).setCode("NCR1").setQcType(MesQcTypeEnum.RQC.getType())
            .setQcId(2L).setItemId(3L).setUnqualifiedQuantity(BigDecimal.TEN); }
    private MesQcRqcDO qc(int source) { return new MesQcRqcDO().setId(2L).setItemId(3L).setSourceDocType(source)
            .setSourceDocId(4L).setSourceLineId(5L).setBatchCode("B1").setStatus(MesQcStatusEnum.FINISHED.getStatus())
            .setUnqualifiedQuantity(BigDecimal.TEN); }
    private MesQcNcrDisposeReqVO request() { var req = new MesQcNcrDisposeReqVO(); req.setWarehouseId(8L); req.setLocationId(9L); req.setAreaId(10L); return req; }
    @Test void rejectsProductionReturn() {
        when(rqc.selectById(2L)).thenReturn(qc(MesBizTypeConstants.WM_RETURN_ISSUE));
        assertThrows(ServiceException.class, () -> service.receive(ncr(), request()));
        verifyNoInteractions(receipts, transactionService);
    }
    @Test void validatesUnstockedIpqcOutputAndKeepsItsOriginalBatch() {
        var item = ncr().setQcType(MesQcTypeEnum.IPQC.getType());
        var qc = new MesQcIpqcDO().setId(2L).setItemId(3L).setSourceDocType(MesBizTypeConstants.PRO_FEEDBACK)
                .setSourceDocId(11L).setSourceLineId(12L).setWorkOrderId(15L)
                .setStatus(MesQcStatusEnum.FINISHED.getStatus()).setUnqualifiedQuantity(BigDecimal.TEN);
        var original = new MesWmProductProduceLineDO().setId(12L).setFeedbackId(11L).setProduceId(13L)
                .setBatchId(7L).setBatchCode("B1");
        var failed = new MesWmProductProduceLineDO().setId(14L).setFeedbackId(11L).setProduceId(13L)
                .setItemId(3L).setQuantity(BigDecimal.TEN).setBatchId(7L).setBatchCode("B1")
                .setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus());
        when(ipqc.selectById(2L)).thenReturn(qc);
        when(outputs.selectById(12L)).thenReturn(original);
        when(produces.selectById(13L)).thenReturn(new MesWmProductProduceDO().setId(13L).setFeedbackId(11L).setWorkOrderId(15L));
        when(outputs.selectListByFeedbackId(11L)).thenReturn(List.of(failed));
        when(orders.selectById(15L)).thenReturn(new MesProWorkOrderDO().setId(15L).setCode("WO1"));
        var source = service.validateIpqcSource(item);
        assertEquals(14L, source.sourceLineId());
        assertEquals(7L, source.batchId());
        assertEquals("B1", source.batchCode());
        when(transactions.selectCount(any())).thenReturn(1L);
        assertThrows(ServiceException.class, () -> service.validateIpqcSource(item));
        failed.setBatchCode("other");
        assertThrows(ServiceException.class, () -> service.validateIpqcSource(item));
    }
    @Test void onlyOneNcrCanClaimTheFailedOutput() {
        var item = ncr().setQcType(MesQcTypeEnum.IPQC.getType());
        var source = new MesQcNcrScrapService.IpqcSource(15L, "WO1", 14L, 7L, "B1");
        when(outputs.update(isNull(), any(Wrapper.class))).thenReturn(1, 0);
        assertDoesNotThrow(() -> service.claimIpqcSource(item, source));
        assertThrows(ServiceException.class, () -> service.claimIpqcSource(item.setId(99L), source));
        verify(outputs, times(2)).update(isNull(), any(Wrapper.class));
    }
    @Test void recordsCustomerReturnInScrapWarehouseWithOriginalBatch() {
        when(rqc.selectById(2L)).thenReturn(qc(MesBizTypeConstants.WM_RETURN_SALES));
        var line = new MesWmReturnSalesLineDO().setId(6L).setReturnId(4L).setRqcId(2L).setItemId(3L)
                .setQuantity(BigDecimal.TEN).setBatchId(7L).setBatchCode("B1").setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus());
        when(lines.selectListByReturnId(4L)).thenReturn(List.of(line));
        when(warehouse.validateWarehouseExists(8L)).thenReturn(new MesWmWarehouseDO().setWarehouseType("废料仓"));
        var response = service.receive(ncr(), request());
        assertEquals("SCRAP_RECEIPT", response.getDocType());
        assertEquals("SCR-N" + MesQcTypeEnum.RQC.getType() + "-6", response.getDocCode());
        verify(transactionService).createTransaction(argThat(tx -> tx.getItemId().equals(3L) && tx.getBatchId().equals(7L)
                && tx.getQuantity().compareTo(BigDecimal.TEN) == 0 && tx.getWarehouseId().equals(8L)));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcNcrStockClosureGuardTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.enums.qc.MesNcrDispositionEnum;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MesQcNcrStockClosureGuardTest {
    private final MesQcNcrStockClosureGuard guard = new MesQcNcrStockClosureGuard();
    @Test
    void blocksInventoryGeneratingDispositionsUntilSourceAndStockAreConnected() {
        assertThrows(ServiceException.class, () -> guard.validateBeforeDispose(MesNcrDispositionEnum.SCRAP.getType()));
        assertThrows(ServiceException.class, () -> guard.validateBeforeDispose(MesNcrDispositionEnum.CONCESSION.getType()));
        assertDoesNotThrow(() -> guard.validateBeforeDispose(MesNcrDispositionEnum.REWORK.getType()));
        assertDoesNotThrow(() -> guard.validateBeforeDispose(MesNcrDispositionEnum.REPAIR.getType()));
    }
    @Test
    void blocksClosingLegacyScrapWithoutDestroyEvidence() {
        MesQcNcrDO ncr = new MesQcNcrDO().setDisposition(MesNcrDispositionEnum.SCRAP.getType());
        assertThrows(ServiceException.class, () -> guard.validateBeforeClose(ncr));
        assertEquals(MesNcrDispositionEnum.SCRAP.getType(), ncr.getDisposition());
        assertThrows(ServiceException.class, () -> guard.validateBeforeClose(new MesQcNcrDO().setDisposition(MesNcrDispositionEnum.CONCESSION.getType())));
        assertDoesNotThrow(() -> guard.validateBeforeClose(new MesQcNcrDO().setDisposition(MesNcrDispositionEnum.REWORK.getType())));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseConcessionServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.controller.admin.qc.ncr.vo.MesQcNcrDisposeReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.itemreceipt.vo.line.MesWmItemReceiptLineSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseReturnDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseConcessionMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseReturnMapper;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.MesWmItemReceiptService;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.MesWmItemReceiptLineService;
import cn.iocoder.yudao.module.mes.service.wm.itemreceipt.MesWmItemReceiptDetailService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesQcPurchaseConcessionServiceTest {
    @Test void postsOnlyConcessionThroughPurchaseReceiptAndPreventsDuplicateReturn() {
        var service = new MesQcPurchaseConcessionService();
        var validator = mock(MesQcNcrPurchaseSourceValidator.class);
        var concessions = mock(MesQcPurchaseConcessionMapper.class);
        var returns = mock(MesQcPurchaseReturnMapper.class);
        var receipts = mock(MesWmItemReceiptService.class);
        var lines = mock(MesWmItemReceiptLineService.class);
        var details = mock(MesWmItemReceiptDetailService.class);
        var arrival = mock(MesQcArrivalDispositionService.class);
        ReflectionTestUtils.setField(service, "validator", validator);
        ReflectionTestUtils.setField(service, "concessionMapper", concessions);
        ReflectionTestUtils.setField(service, "returnMapper", returns);
        ReflectionTestUtils.setField(service, "receipts", receipts);
        ReflectionTestUtils.setField(service, "lines", lines);
        ReflectionTestUtils.setField(service, "details", details);
        ReflectionTestUtils.setField(service, "arrivalDispositionService", arrival);
        var ncr = new MesQcNcrDO().setId(1L).setCode("NCR1").setQcId(2L).setItemId(3L).setUnqualifiedQuantity(BigDecimal.TEN);
        var notice = new MesWmArrivalNoticeDO().setId(4L).setVendorId(5L).setPurchaseOrderId(6L);
        var line = new MesWmArrivalNoticeLineDO().setId(7L).setPurchaseOrderItemId(8L);
        when(validator.validate(ncr)).thenReturn(new MesQcNcrPurchaseSourceValidator.Source(new MesQcIqcDO(), notice, line));
        when(receipts.createItemReceipt(any())).thenReturn(9L);
        when(lines.createItemReceiptLine(any())).thenReturn(10L);
        when(lines.getItemReceiptLine(10L)).thenReturn(new MesWmItemReceiptLineDO().setId(10L));
        var request = new MesQcNcrDisposeReqVO();
        request.setDisposition(4); request.setWarehouseId(11L); request.setLocationId(12L); request.setAreaId(13L);
        var result = service.dispose(ncr, request);
        assertEquals(9L, result.getDocId());
        var lineRequest = ArgumentCaptor.forClass(MesWmItemReceiptLineSaveReqVO.class);
        verify(lines).createItemReceiptLine(lineRequest.capture());
        assertEquals(BigDecimal.TEN, lineRequest.getValue().getReceivedQuantity());
        assertNull(lineRequest.getValue().getArrivalNoticeLineId());
        verify(receipts).finishItemReceipt(9L);
        verify(arrival).refresh(4L);
        when(returns.selectByIqcId(2L)).thenReturn(new MesQcPurchaseReturnDO());
        assertThrows(ServiceException.class, () -> service.dispose(ncr, request));
        verify(receipts, times(1)).createItemReceipt(any());
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/ncr/MesQcPurchaseReturnServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.mes.service.qc.ncr;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.iqc.MesQcIqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcNcrDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseReturnDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ncr.MesQcPurchaseConcessionDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseReturnMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcPurchaseConcessionMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class MesQcPurchaseReturnServiceTest {
    @Test void createsTraceableWaitingDocumentAndRequiresWarehouseConfirmation() {
        var service = new MesQcPurchaseReturnService();
        var validator = mock(MesQcNcrPurchaseSourceValidator.class);
        var mapper = mock(MesQcPurchaseReturnMapper.class);
        var concessions = mock(MesQcPurchaseConcessionMapper.class);
        ReflectionTestUtils.setField(service, "sourceValidator", validator);
        ReflectionTestUtils.setField(service, "mapper", mapper);
        ReflectionTestUtils.setField(service, "concessionMapper", concessions);
        var ncr = new MesQcNcrDO().setId(1L).setQcId(2L).setItemId(3L).setUnqualifiedQuantity(BigDecimal.TEN);
        var iqc = new MesQcIqcDO().setId(2L).setVendorBatch("B1");
        var notice = new MesWmArrivalNoticeDO().setId(4L).setVendorId(5L).setPurchaseOrderId(6L);
        var line = new MesWmArrivalNoticeLineDO().setId(7L).setPurchaseOrderItemId(8L);
        when(validator.validate(ncr)).thenReturn(new MesQcNcrPurchaseSourceValidator.Source(iqc, notice, line));
        var doc = service.createForNcr(ncr);
        assertEquals(0, doc.getStatus());
        assertEquals(8L, doc.getPurchaseOrderItemId());
        assertEquals(BigDecimal.TEN, doc.getQuantity());
        verify(mapper).insert(any(MesQcPurchaseReturnDO.class));
        when(mapper.selectByNcrId(1L)).thenReturn(doc);
        assertThrows(ServiceException.class, () -> service.createForNcr(ncr));
        assertThrows(ServiceException.class, () -> service.validateConfirmed(1L));
        doc.setStatus(1);
        assertDoesNotThrow(() -> service.validateConfirmed(1L));
        when(mapper.selectByNcrId(1L)).thenReturn(null);
        when(concessions.selectByIqcId(2L)).thenReturn(new MesQcPurchaseConcessionDO());
        assertThrows(ServiceException.class, () -> service.createForNcr(ncr));
        verify(mapper, times(1)).insert(any(MesQcPurchaseReturnDO.class));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/miscissue/MesWmMiscIssueServiceImplTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
package cn.iocoder.yudao.module.mes.service.wm.miscissue;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.miscissue.MesWmMiscIssueDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.miscissue.MesWmMiscIssueLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.miscissue.MesWmMiscIssueMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmMiscIssueStatusEnum;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesWmMiscIssueServiceImplTest {
    @Test
    void finishMiscIssuePassesSelectedMaterialStockIdToTransaction() {
        var service = new MesWmMiscIssueServiceImpl();
        var mapper = mock(MesWmMiscIssueMapper.class);
        var lineService = mock(MesWmMiscIssueLineService.class);
        var transactionService = mock(MesWmTransactionService.class);
        ReflectionTestUtils.setField(service, "miscIssueMapper", mapper);
        ReflectionTestUtils.setField(service, "miscIssueLineService", lineService);
        ReflectionTestUtils.setField(service, "wmTransactionService", transactionService);
        var issue = new MesWmMiscIssueDO().setId(1L).setCode("MI1")
                .setStatus(MesWmMiscIssueStatusEnum.APPROVED.getStatus());
        var line = new MesWmMiscIssueLineDO().setId(5L).setIssueId(1L).setItemId(2L)
                .setQuantity(BigDecimal.valueOf(3)).setMaterialStockId(9L).setBatchId(6L)
                .setBatchCode("B1").setWarehouseId(11L).setLocationId(12L).setAreaId(13L);
        when(mapper.selectById(1L)).thenReturn(issue);
        when(lineService.getMiscIssueLineListByIssueId(1L)).thenReturn(List.of(line));
        service.finishMiscIssue(1L);
        @SuppressWarnings("unchecked")
        ArgumentCaptor<List<MesWmTransactionSaveReqDTO>> captor = ArgumentCaptor.forClass(List.class);
        verify(transactionService).createTransactionList(captor.capture());
        var dto = captor.getValue().get(0);
        assertEquals(9L, dto.getMaterialStockId().longValue());
        assertEquals(6L, dto.getBatchId().longValue());
        assertEquals(0, BigDecimal.valueOf(-3).compareTo(dto.getQuantity()));
    }
    @Test
    void finishMiscIssueRejectsNonApprovedStatus() {
        var service = new MesWmMiscIssueServiceImpl();
        var mapper = mock(MesWmMiscIssueMapper.class);
        ReflectionTestUtils.setField(service, "miscIssueMapper", mapper);
        ReflectionTestUtils.setField(service, "miscIssueLineService", mock(MesWmMiscIssueLineService.class));
        ReflectionTestUtils.setField(service, "wmTransactionService", mock(MesWmTransactionService.class));
        when(mapper.selectById(1L)).thenReturn(new MesWmMiscIssueDO().setId(1L).setCode("MI1")
                .setStatus(MesWmMiscIssueStatusEnum.PREPARE.getStatus()));
        assertThrows(ServiceException.class, () -> service.finishMiscIssue(1L));
        verifyNoInteractions(ReflectionTestUtils.getField(service, "wmTransactionService"));
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmOqcConcessionGuardTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.mes.service.wm.productsales;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.oqc.MesQcOqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ncr.MesQcNcrMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MesWmOqcConcessionGuardTest {
    @Test void enforcesInspectedBatchAndWholeLineQuantity() {
        var guard = new MesWmOqcConcessionGuard();
        var ncrs = mock(MesQcNcrMapper.class);
        var oqcs = mock(MesQcOqcMapper.class);
        var stocks = mock(MesWmMaterialStockMapper.class);
        ReflectionTestUtils.setField(guard, "ncrMapper", ncrs);
        ReflectionTestUtils.setField(guard, "oqcMapper", oqcs);
        ReflectionTestUtils.setField(guard, "stockMapper", stocks);
        var line = new MesWmProductSalesLineDO().setId(1L).setItemId(2L).setOqcId(3L).setQuantity(BigDecimal.TEN);
        when(ncrs.selectCount(any(Wrapper.class))).thenReturn(1L);
        when(oqcs.selectById(3L)).thenReturn(new MesQcOqcDO().setSourceLineId(1L).setItemId(2L).setBatchCode("B1"));
        when(stocks.selectById(4L)).thenReturn(new MesWmMaterialStockDO().setItemId(2L).setBatchCode("B1"));
        when(stocks.selectById(5L)).thenReturn(new MesWmMaterialStockDO().setItemId(2L).setBatchCode("B2"));
        assertDoesNotThrow(() -> guard.validate(line, 4L, "B1", BigDecimal.TEN));
        assertThrows(ServiceException.class, () -> guard.validate(line, 5L, "B1", BigDecimal.TEN));
        assertThrows(ServiceException.class, () -> guard.validate(line, 4L, "B2", BigDecimal.TEN));
        assertThrows(ServiceException.class, () -> guard.validate(line, null, "B1", BigDecimal.TEN));
        assertThrows(ServiceException.class, () -> guard.validate(line, 4L, "B1", BigDecimal.ZERO));
        assertDoesNotThrow(() -> guard.validateQuantity(line, BigDecimal.TEN));
        assertThrows(ServiceException.class, () -> guard.validateQuantity(line, BigDecimal.ONE));
    }
}
yudao-module-mes/src/test/resources/sql/create_tables.sql
@@ -353,6 +353,7 @@
    "expire_date" timestamp DEFAULT NULL,
    "lot_number" varchar(128) DEFAULT NULL,
    "quality_status" int DEFAULT NULL,
    "ncr_disposition_id" bigint DEFAULT NULL,
    "remark" varchar(500) DEFAULT NULL,
    "creator" varchar(64) DEFAULT '',
    "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -485,6 +486,7 @@
    "oqc_check_flag" tinyint DEFAULT NULL,
    "oqc_id" bigint DEFAULT NULL,
    "quality_status" int DEFAULT NULL,
    "ncr_disposition_id" bigint DEFAULT NULL,
    "remark" varchar(500) DEFAULT NULL,
    "creator" varchar(64) DEFAULT '',
    "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -509,6 +511,7 @@
    "rqc_id" bigint DEFAULT NULL,
    "rqc_check_flag" bit NOT NULL DEFAULT FALSE,
    "quality_status" int DEFAULT NULL,
    "ncr_disposition_id" bigint DEFAULT NULL,
    "remark" varchar(500) DEFAULT '',
    "creator" varchar(64) DEFAULT '',
    "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,