6 天以前 1e123568c2f561013f5636e45dc0db30b17a3517
feat(basic): 添加产品条码功能并完善生产统计报表

- 在 ProductModel 中新增 barcode 字段及相应校验逻辑
- 实现根据条码查询产品规格的功能接口
- 添加生产报工记录中的设备信息字段
- 实现计件工资汇总统计及导出功能
- 实现产量统计报表(按设备/工序/产品维度)
- 完善 BOM 齐套分析功能
- 添加库存预警查询及导出功能
- 优化检验流程中的状态校验和幂等处理
- 修复产品删除时的关联检查逻辑
已添加75个文件
已修改24个文件
5904 ■■■■■ 文件已修改
docs/img.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块10-材料消耗核算.md 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块11-成本核算分析.md 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块3-生产质量追溯.md 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块4-BOM物料清单.md 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块5+2-生产报工计件与设备产量统计.md 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块6-原料检验入库.md 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块7-成品检验管理.md 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块8-实时库存管理盘点与预警.md 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/前端联调文档-模块9-产品维护.md 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/controller/ProductController.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/dto/ProductModelExportDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/pojo/ProductModel.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/IProductModelService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/ProductServiceImpl.java 36 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostEnergyVo.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostLaborVo.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostMaterialInputVo.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostMaterialPriceVo.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostMissingPriceVo.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostOrderVo.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostSalePriceVo.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostStatVo.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/bean/vo/CostSummaryVo.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/controller/CostAccountingController.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/mapper/CostAccountingMapper.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/service/CostAccountingService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/cost/service/impl/CostAccountingServiceImpl.java 237 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/controller/ProcurementDemandController.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/mapper/ProcurementDemandMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/pojo/ProcurementDemand.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/service/ProcurementDemandService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/service/impl/ProcurementDemandServiceImpl.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/dto/ProductionProductMainDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/DeviceOutputStatVo.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionActualVo.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionOrderVo.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionStatVo.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionStructureVo.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/PieceWageSummaryVo.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/MaterialConsumptionController.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/PieceRateConfigController.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionProductMainController.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/mapper/MaterialConsumptionMapper.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/mapper/PieceRateConfigMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/mapper/ProductionProductMainMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/PieceRateConfig.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/ProductionProductMain.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/MaterialConsumptionService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/PieceRateConfigService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/ProductionProductMainService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/MaterialConsumptionServiceImpl.java 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/PieceRateConfigServiceImpl.java 127 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/controller/QualityTraceController.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceBatchOptionVo.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceChainVo.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceFinishedBatchVo.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceForwardExportVo.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceForwardVo.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceInputVo.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceInspectVo.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceMaterialBatchVo.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceOutboundVo.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTracePickVo.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceReportVo.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceReverseExportVo.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceReverseOrderVo.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceReverseVo.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/dto/QualityTraceUnqualifiedVo.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/mapper/QualityTraceMapper.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/QualityTraceService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/impl/QualityTraceServiceImpl.java 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/impl/QualityUnqualifiedServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/controller/StockCheckController.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/controller/StockInventoryController.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockCheckStartDto.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/StockInventoryDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockCheckDetailMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockCheckMainMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/pojo/StockCheckDetail.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/pojo/StockCheckMain.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/StockCheckService.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockCheckServiceImpl.java 336 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/bean/dto/BomKitCheckDto.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/bean/vo/BomKitCheckItemVo.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/controller/TechnologyBomController.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/pojo/TechnologyBom.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/TechnologyBomService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/ProductModelMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/cost/CostAccountingMapper.xml 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/MaterialConsumptionMapper.xml 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionProductMainMapper.xml 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/quality/QualityTraceMapper.xml 313 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockInventoryMapper.xml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/img.png
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é10-²ÄÁÏÏûºÄºËËã.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—10:材料消耗核算(BOM标准 vs å®žé™…投入)
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
纯查询报表(无新表、无 DDL),按"生产订单 Ã— ç‰©æ–™"输出:
| åˆ— | å£å¾„ |
|---|---|
| BOM单耗 | è®¢å•BOM结构(`production_bom_structure`)逐层展开后的**累计单耗**(上级累计×本级用量) |
| å®Œå·¥æ•°é‡ | è®¢å•完工数量;未完工时回退用计划数量(该口径下差异仅供参考) |
| æ ‡å‡†æ¶ˆè€— | BOM累计单耗 Ã— å®Œå·¥æ•°é‡ |
| å®žé™…消耗 | æŠ¥å·¥æŠ•入合计(`production_product_input.input_quantity`,经工单归到订单) |
| å·®å¼‚数量/差异率 | å®žé™…−标准;差异率=差异÷标准×100(标准为0时为空) |
| BOM外投入 | BOM没有但报工投过的物料也会出行(单耗/标准为0),用于发现误领/超标物料 |
## äºŒã€æŽ¥å£ï¼ˆè¿”回 R:{code:200,data:[...]})
### 1. æ ¸ç®—列表
`GET /materialConsumption/stat`
| å‚æ•° | è¯´æ˜Ž |
|---|---|
| startDate / endDate | è®¢å•日期范围,`yyyy-MM-dd`。口径=`coalesce(create_time, start_time)`(历史订单 create_time ä¸ºç©ºæ—¶å›žé€€å¼€å·¥æ—¥æœŸï¼Œé¿å…ç­›ä¸¢ï¼‰ |
| npsNo | ç”Ÿäº§è®¢å•号模糊 |
| productName | æˆå“åç§°/规格模糊 |
| onlyDiff | true=只保留差异≠0 è¡Œ |
返回行字段:`productionOrderId, npsNo, finishedProduct, materialModelId, materialName, materialModel, unit, unitQuantity, completeQty, standardQty, actualQty, diffQty, diffRate`
### 2. å¯¼å‡º
`GET /materialConsumption/stat/export` 参数同上,Excel æµï¼ˆæ–‡ä»¶å"材料消耗核算"),前端 blob ä¸‹è½½ã€‚
## ä¸‰ã€é¡µé¢å»ºè®®
- **菜单(已加)**:名称「材料消耗核算」,menu_id=2350,父级「生产管控」(2058),路由 `materialConsumption`,component `productionManagement/materialConsumption/index`,可见角色 æ™®é€š/制造。
- é¡µé¢å½¢æ€ï¼šé¡¶éƒ¨ç­›é€‰ï¼ˆæ—¥æœŸèŒƒå›´/订单号/成品)+ "只看差异"开关 + å¯¼å‡ºæŒ‰é’®ã€‚
- åˆ—表按订单分组展示(npsNo/finishedProduct åˆ—可合并单元格),差异率>10% æ ‡çº¢ã€‚
- å»ºè®®æä¾›ä»Žæ ¸ç®—页点击订单号跳转生产订单详情的联动。
## å››ã€è”调自测清单
- [ ] å·²å®Œå·¥è®¢å•:标准=单耗×完工、实际=该订单报工投入合计,与数据库手工核对一致
- [ ] å¤šå±‚BOM:半成品层物料单耗为逐层累计值(上级用量×本级用量)
- [ ] BOM外投入物料出现在列表且 diffQty=actualQty(标准为0、差异率为空)
- [ ] onlyDiff=true æ—¶å·®å¼‚0行被过滤
- [ ] æœªå®Œå·¥è®¢å•回退计划量口径
- [ ] å¯¼å‡ºå†…容与列表一致
## äº”、遗留说明
- å®žé™…消耗取"报工投入"口径;若客户后续要求按"领料出库"口径(production_order_pick),二期加 `dataSource=pick` å‚数切换。
- å·®å¼‚金额(×采购均价)归入模块11成本核算。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é11-³É±¾ºËËã·ÖÎö.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—11:成本核算分析(材料+人工+能耗)
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
纯查询报表(无新表、无 DDL),以"生产订单"为核算主体,输出成本核算明细、成本构成汇总、缺价物料清单三个视图,均可导出 Excel。
### æˆæœ¬å£å¾„
| æˆæœ¬é¡¹ | å£å¾„ |
|---|---|
| ææ–™æˆæœ¬ | Î£(该订单各物料报工投入量 Ã— é‡‡è´­å•ä»·)。投入量取 `production_product_input.input_quantity`(经工单归到订单),与模块10"实际消耗"同口径 |
| é‡‡è´­å•ä»· | æŒ‰ä¼˜å…ˆçº§å–第一个有价来源:**采购价目表 > é‡‡è´­æ˜Žç»†(销售台账 type=2) > é‡‡è´­å…¥åº“记录 > é‡‡è´­å‘票登记**;同表内取最新一条(max(id)) |
| äººå·¥æˆæœ¬ | ä¸Žæ¨¡å—5工资汇总同公式:计件单价(`piece_rate_config`) > è®¡æ—¶(`poro.type=0`:工时×报工主表工时单价) > å…¶ä»–(工时×完工数×规格内嵌单价,即规格末尾 `*数字` æ®µï¼‰ |
| èƒ½è€—成本 | ç­›é€‰åŒºé—´å†… `Σ(设备用电量×当日电价)`,按订单**产量占比**分摊到各订单 |
| äº§é‡ | å®Œå·¥æ•°é‡ä¼˜å…ˆï¼Œæœªå®Œå·¥å›žé€€è®¡åˆ’数量(与模块10一致) |
| é”€å”®å•ä»· | è®¢å•关联生产计划→销售单,取销售台账 `type=1` å«ç¨Žå•价,多张时取最新 |
| æ¯›åˆ© / æ¯›åˆ©çއ | (销售单价−单位成本)×产量;毛利 Ã· (销售单价×产量) Ã—100 |
| å•位成本 | æ€»æˆæœ¬ Ã· äº§é‡ï¼ˆäº§é‡ä¸º0时为空) |
### æ•°æ®è´¨é‡è¾…助列
- **价格覆盖率%**:有采购价的物料消耗量 Ã· å…¨éƒ¨æ¶ˆè€—量 Ã—100。低于100说明材料成本被低估,需补录单价。
- **缺价物料数**:该订单投入了但四个来源都查不到单价的物料种类数。
- **无工资标准记录**:报工核算记录中"工时为0且无计件单价"的行数,>0 è¯´æ˜Žäººå·¥æˆæœ¬ä¸å¯ä¿¡ï¼Œéœ€è¡¥å½•工时或维护计件单价。
## äºŒã€æŽ¥å£ï¼ˆè¿”回 R:{code:200,data:[...]})
公共筛选参数(三个查询接口一致):
| å‚æ•° | è¯´æ˜Ž |
|---|---|
| startDate / endDate | è®¢å•日期范围,`yyyy-MM-dd`。**日期口径= `coalesce(create_time, start_time)`**(历史订单 create_time ä¸ºç©ºæ—¶å›žé€€å¼€å·¥æ—¥æœŸï¼Œé¿å…ç­›ä¸¢ï¼‰ |
| npsNo | ç”Ÿäº§è®¢å•号模糊 |
| productName | æˆå“åç§°/规格模糊 |
### 1. æˆæœ¬æ ¸ç®—明细(按订单)
`GET /costAccounting/stat`
返回行字段:
`productionOrderId, npsNo, finishedProduct, unit, orderDate, outputQty, materialCost, laborCost, energyCost, totalCost, unitCost, salePrice, grossProfit, grossProfitRate, priceCoverageRate, missingPriceKinds, noRateRows`
金额均为 BigDecimal,保留2位(unitCost 4位);salePrice/grossProfit/grossProfitRate å¯èƒ½ä¸º null(无关联销售单或产量为0),前端需做空值展示(如"—")。
### 2. æˆæœ¬æž„成汇总
`GET /costAccounting/summary?groupBy=month|product`(默认 month)
- `groupBy=month`:按订单日期月份(`yyyy-MM`,无日期归"未填日期")聚合
- `groupBy=product`:按成品(名称/规格)聚合
返回行字段:
`groupKey, orderCount, outputQty, materialCost, laborCost, energyCost, totalCost, unitCost, materialRate, laborRate, energyRate, grossProfit, grossProfitRate`
占比列(materialRate ç­‰ï¼‰= å¯¹åº”成本 Ã· æ€»æˆæœ¬ Ã—100,适合前端画堆积饼图/柱图。
### 3. ç¼ºä»·ç‰©æ–™æ¸…单
`GET /costAccounting/missingPrice`
返回筛选范围内**有消耗但无任何采购单价**的物料:
`productModelId, materialName, materialModel, unit, inputQty, orderCount, lastInputTime`
用途:作为补录采购单价的工作清单(点击可跳转物料维护/价目表页面)。
### 4. å¯¼å‡ºï¼ˆExcel æµï¼Œå‰ç«¯ blob ä¸‹è½½ï¼‰
| æŽ¥å£ | æ–‡ä»¶å | å‚æ•° |
|---|---|---|
| `GET /costAccounting/stat/export` | æˆæœ¬æ ¸ç®—明细 | åŒ stat |
| `GET /costAccounting/summary/export` | æˆæœ¬æž„成汇总 | åŒ summary |
| `GET /costAccounting/missingPrice/export` | ç¼ºä»·ç‰©æ–™æ¸…单 | åŒ missingPrice |
## ä¸‰ã€é¡µé¢å»ºè®®
- **菜单(已加)**:名称「成本核算分析」,menu_id=2351,父级「生产管控」(2058),路由 `costAccounting`,component `productionManagement/costAccounting/index`,可见角色 æ™®é€š/HR/管理/财务/制造。注意挂的是「生产管控」而非「管理驾驶舱」——后者(2036)当前是停用状态,挂上去不会显示。
- é¡µé¢å½¢æ€ï¼šä¸‰ä¸ª Tab:
  1. **核算明细**:顶部筛选 + è¡¨æ ¼ï¼›`priceCoverageRate<100` æˆ– `missingPriceKinds>0` çš„行"缺价物料数"标橙;`noRateRows>0` çš„"人工成本"标橙并 tooltip æç¤ºè¡¥å½•;毛利率为负标红。
  2. **构成汇总**:月份/成品切换 + ææ–™/人工/能耗占比堆积图 + è¡¨æ ¼ï¼›ç‚¹å‡»æœˆä»½å¯å¸¦æ—¥æœŸèŒƒå›´è·³è½¬æ˜Žç»† Tab。
  3. **缺价物料**:表格 + "去维护单价"按钮(跳采购价目表/产品维护页)。
- æ˜Žç»†é¡µç‚¹å‡»è®¢å•号可跳转生产订单详情(与模块3/10 è”动一致)。
## å››ã€è”调自测清单
- [ ] stat:任选已完工订单,材料成本 = Î£(投入量×有价物料单价),与数据库手工核对一致(dev å·²éªŒè¯è®¢å•1225:产量1706,材料成本46266.72,单位成本≈27.12)
- [ ] æ—¥æœŸç­›é€‰åœ¨ create_time å…¨ç©ºçš„历史数据上仍能命中(走 start_time å›žé€€ï¼‰
- [ ] summary(groupBy=month):各组成本合计 = æ˜Žç»†å¯¹åº”列合计;占比之和≈100(总成本>0 æ—¶ï¼‰
- [ ] summary(groupBy=product):同成品多订单合并、订单数正确
- [ ] missingPrice è¿”回的物料在 stat è¡Œé‡Œ missingPriceKinds å‡è¢«è®¡å…¥
- [ ] æ— å…³è”销售单订单:salePrice/grossProfit ä¸º null,页面不报错
- [ ] ä¸‰ä¸ªå¯¼å‡ºå†…容与列表一致,日期列格式化正确
## äº”、数据现状与遗留说明(dev åº“实测)
- **采购单价来源现状**:四个来源中仅"采购明细(销售台账 type=2)"有真实数据(约79个规格有价,其中当前消耗物料命中4个);采购价目表/采购入库记录/发票登记表暂为空表,代码已按优先级 UNION,客户录入后自动生效,无需改代码。
- **人工成本当前为0**:dev æ•°æ®æŠ¥å·¥å·¥æ—¶(work_hours)全为0且计件单价表为空——属数据缺失而非计算错误,报表用"无工资标准记录"列如实暴露,需车间补录工时或维护 `piece_rate_config`。
- **能耗成本当前为0**:`equipment_energy_consumption`、`energy_period` å‡ä¸ºç©ºè¡¨ï¼›ä¸”"设备数采"(模块1)暂缓,接入数采/手工抄表后自动出数。分摊逻辑为区间电费总额按产量占比,不区分订单实际开机时长,客户如有分表计量需求二期细化。
- **销售单价**依赖订单→生产计划→销售单链路(`production_plan_ids` find_in_set),历史未关联销售单的订单毛利为空。
- æ¨¡å—10/模块3 çš„订单日期筛选已同步改为 `coalesce(create_time, start_time)` å£å¾„,前端联调时注意说明文档一致。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é3-Éú²úÖÊÁ¿×·ËÝ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,171 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—3:生产质量追溯(批次链路)
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现、编译通过,追溯链路已用生产库真实数据验证,待前端联调
> è¯´æ˜Žï¼šçº¯æŸ¥è¯¢æ¨¡å—,无新表、无 DDL、不改任何写入逻辑
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
打通"原料批号 â†” ç”Ÿäº§è®¢å• â†” æŠ¥å·¥å·¥åº â†” æˆå“æ‰¹å· â†” å‡ºåº“流向"的双向链路,用于质量追责与召回圈定范围。
### é“¾è·¯å…³ç³»ï¼ˆåŽç«¯å–数口径)
```
采购/自定义入库(原料批号) â†’ ç”Ÿäº§é¢†æ–™(production_order_pick.batch_no)
   â†’ ç”Ÿäº§è®¢å• â†’ å·¥è‰ºè·¯çº¿å·¥åº/工单 â†’ ç”Ÿäº§æŠ¥å·¥(production_product_main)
        â”œâ”€ æŠ¥å·¥æŠ•å…¥(production_product_input)  â† åŠæˆå“/物料
        â”œâ”€ è¿‡ç¨‹æ£€éªŒ / å‡ºåŽ‚æ£€éªŒ(quality_inspect) â†’ ä¸åˆæ ¼å¤„理(quality_unqualified)
        â””─ åˆæ ¼å…¥åº“ stock_in_record(批号 = æˆå“æ‰¹å·)
              record_type=2 â†’ record_id = æŠ¥å·¥å•id
              record_type=6 â†’ record_id = æ£€éªŒå•id â†’ æ£€éªŒå•.product_main_id = æŠ¥å·¥å•id
   â†’ é”€å”®å‘货出库(stock_out_record,同成品批号)
```
## äºŒã€æŽ¥å£ï¼ˆè¿”回 R:{code:200,data:...};批号为空返回 code≠200,msg="批号不能为空")
### 1. æ­£å‘追溯:成品批号 â†’ å…¨é“¾è·¯
`GET /quality/trace/forward?batchNo={成品批号}`
```jsonc
{
  "batchNo": "20260826-6-001",
  "chains": [                         // ä¸€ä¸ªæ‰¹å·å¯èƒ½å‘½ä¸­å¤šä¸ªè®¢å•,按订单分组
    {
      "productionOrderId": 1198,
      "npsNo": "SC202608231108",
      "finishedProduct": "Q3前扶手左护板/Q3前扶手左护板",
      "unit": "个",
      "planQty": 62.00, "completeQty": 62.00,
      "orderStatus": 3,               // ç”Ÿäº§è®¢å•状态,与列表页字典保持一致
      "orderCreateTime": "2026-08-23 11:08:00",
      "batches": [                    // å‘½ä¸­å…¥å‚批号的入库记录
        { "batchNo": "20260826-6-001", "productMainId": 1170, "productModelId": 503,
          "qty": 62.0000, "inTime": "2026-08-26 00:00:00", "recordType": "6" }
      ],
      "reports": [                    // è¯¥è®¢å•的报工工序节点(含全部工序,不只命中批号的那道)
        { "productMainId": 1170, "workOrderNo": "GD20260823071", "operationName": "打磨",
          "userName": "孙立松", "deviceName": null, "productNo": "BG20260826019",
          "outputQty": 62.0000, "scrapQty": 0.00, "reportTime": "2026-08-26 10:00:00",
          "npsNo": "SC202608231108", "productionOrderId": 1198, "finishedProduct": "...",
          "unit": "个", "planQty": 62.00, "completeQty": 62.00, "orderStatus": 3, "orderCreateTime": "..." }
      ],
      "inputs": [                     // æŠ¥å·¥æŠ•入物料(按 reports çš„ productMainId å½’属)
        { "productMainId": 1170, "productModelId": 503, "materialName": "Q3前扶手左护板",
          "materialModel": "Q3前扶手左护板", "unit": "个", "inputQty": 62.000000,
          "workOrderNo": "GD20260823071", "operationName": "打磨" }
      ],
      "picks": [                      // è¯¥è®¢å•的领料批次(原料批号),已回溯供应商/检验结论
        { "productionOrderId": 1198, "productModelId": 704, "materialName": "HDPE HE120 ç™½è‰²",
          "materialModel": "HDPE HE120", "unit": "kg", "batchNo": "20260801-HDPE HE120-001",
          "quantity": 5.96, "operationName": "下料", "pickTime": "2026-08-23 14:00:00",
          "supplierName": null,       // è¯¥æ‰¹å·ä¸ºè‡ªå®šä¹‰å…¥åº“时无供应商,采购入库批号会带出
          "contractNo": null, "checkResult": null }
      ],
      "inspects": [                   // è¿‡ç¨‹/出厂检验单
        { "inspectId": 1825, "productMainId": 1170, "inspectType": 2, "inspectTypeName": "出厂检验",
          "process": null, "checkName": "å¼ å·¥", "productName": "Q3前扶手左护板", "model": "Q3前扶手左护板",
          "unit": "个", "quantity": 62.00, "qualifiedQuantity": 62.00, "unqualifiedQuantity": 0.00,
          "checkResult": "合格", "checkTime": "2026-08-25", "inspectState": 1,
          "defectivePhenomena": null, "unqualifiedList": [] }
      ],
      "unqualifiedList": [            // ä¸åˆæ ¼å¤„理(扁平化,便于时间线展示;亦在 inspects[].unqualifiedList ä¸­ï¼‰
        { "id": 12, "inspectId": 1825, "productName": "...", "model": "...", "unit": "个",
          "quantity": 1.00, "defectivePhenomena": "外观划伤", "dealResult": "报废",
          "dealName": "王强", "dealTime": "2026-08-26", "inspectState": 1, "checkTime": "2026-08-25" }
      ]
    }
  ],
  "outbounds": [                      // åŒæ‰¹å·çš„出库流向(销售发货/退货等)
    { "id": 1349, "batchNo": "20260826-6-001", "productName": "Q3前扶手左护板",
      "productModel": "Q3前扶手左护板", "unit": "个", "qty": 62.0000,
      "recordType": "13", "outTime": "2026-08-26 10:00:00", "remark": null }
  ]
}
```
- `recordType` å–值:入库 `2`=生产报工入库、`6`=质检合格入库;出库 `13`=销售发货出库、`8`=销售出库、`3`=生产报工出库、`1`=自定义出库。
- æœªå‘½ä¸­ä»»ä½•链路时 `chains`/`outbounds` å‡ä¸ºç©ºæ•°ç»„(不是报错),前端提示"未查询到该批号的追溯记录"。
### 2. åå‘追溯:原料批号 â†’ å—影响成品
`GET /quality/trace/reverse?batchNo={原料批号}`
```jsonc
{
  "batchNo": "20260801-PA6 B100HS-UV 7CN MP25-001",
  "material": {                       // è¯¥æ‰¹å·çš„入库/检验/采购来源;无入库记录时为 null
    "inRecordId": 300, "batchNo": "20260801-PA6 B100HS-UV 7CN MP25-001",
    "productModelId": 705, "materialName": "PA6 B100HS-UV 7CN", "materialModel": "MP25",
    "unit": "kg", "qty": 5000.0000, "inTime": "2026-08-01 09:00:00", "recordType": "10",
    "inspectId": 664, "inspectQty": 5000.00, "qualifiedQty": 5000.00, "unqualifiedQty": 0.00,
    "checkResult": "合格", "checkTime": "2026-08-01",
    "supplierName": "沧州慧安五金制品有限公司", "contractNo": "CG20260730455", "entryDate": "2026-08-01"
  },
  "orders": [                         // é¢†ç”¨è¯¥æ‰¹å·çš„æ‰€æœ‰ç”Ÿäº§è®¢å•(召回范围)
    {
      "productionOrderId": 97, "npsNo": "SC202608010007",
      "finishedProduct": "VW316-6前扶手安装支架/K31 P58米色", "unit": "个",
      "planQty": 5000.00, "completeQty": 5000.00, "orderStatus": 3,
      "orderCreateTime": "2026-08-01 08:30:00",
      "pickedQty": 575.00,            // æœ¬è®¢å•领用被查批号的合计
      "picks": [ { "batchNo": "20260801-PA6 B100HS-UV 7CN MP25-001", "quantity": 575.00,
                   "operationName": "下料", "pickTime": "2026-08-01 10:00:00", "materialName": "...",
                   "materialModel": "...", "unit": "kg", "productModelId": 705,
                   "productionOrderId": 97, "supplierName": "...", "contractNo": "...", "checkResult": "合格" } ],
      "reports": [ /* ç»“构同正向 reports */ ],
      "finishedBatches": [            // è¯¥è®¢å•产出的全部成品批号
        { "productionOrderId": 97, "productMainId": 157, "batchNo": "20260801-6-011",
          "productModelId": 505, "qty": 5000.0000, "inTime": "2026-08-02 00:00:00", "recordType": "6" }
      ]
    }
  ]
}
```
- ä¸€ä¸ªåŽŸæ–™æ‰¹å·å¸¸è¢«å¤šä¸ªè®¢å•é¢†ç”¨ï¼Œ`orders` å¯èƒ½æœ‰å¤šæ¡ï¼›æ¯æ¡è®¢å•çš„ `finishedBatches` å³å¯ç›´æŽ¥ç”¨äºŽè·³è½¬æ­£å‘追溯。
- è‹¥åŽŸæ–™æ‰¹å·åªæœ‰å…¥åº“/采购记录但从未领料:`material` æœ‰å€¼ã€`orders` ä¸ºç©ºæ•°ç»„。
### 3. æ‰¹å·å€™é€‰ï¼ˆæœç´¢ä¸‹æ‹‰ï¼‰
`GET /quality/trace/batchOptions?type=finished|material&keyword={模糊}&limit={默认50,最大200}`
返回 `[{batchNo, productName, productModel, unit, qty, inTime, npsNo}]`,按时间倒序。
- `type=finished`:取自生产入库/质检合格入库的成品批号(含可关联的订单号,供正向追溯)。
- `type=material`:取自生产领料的原料批号(供反向追溯)。
### 4. å¯¼å‡ºï¼ˆExcel æµï¼Œå‰ç«¯ blob ä¸‹è½½ï¼‰
| æŽ¥å£ | æ–‡ä»¶å | è¡Œå«ä¹‰ |
|---|---|---|
| `GET /quality/trace/forward/export?batchNo=` | æˆå“æ‰¹æ¬¡è¿½æº¯ | æ¯è¡Œ = è®¢å• Ã— é¢†æ–™åŽŸæ–™æ‰¹å·ï¼›å«å·¥åºæ±‡æ€»ã€æ£€éªŒç»“è®ºæ±‡æ€»ã€ä¸åˆæ ¼å¤„ç†æ±‡æ€»ã€åŽŸæ–™ä¾›åº”å•†ä¸Žæ£€éªŒç»“è®ºï¼ˆè®¢å•æ— é¢†æ–™æ—¶è¾“å‡ºä¸€è¡Œç©ºç‰©æ–™ï¼‰ |
| `GET /quality/trace/reverse/export?batchNo=` | åŽŸæ–™æ‰¹æ¬¡æµå‘è¿½æº¯ | æ¯è¡Œ = å—影响订单,含原料来源(供应商/合同/采购日期/检验结论)、领用数量、该订单产出成品批号(多个以 `/` åˆ†éš”) |
导出列:`成品批号 ç”Ÿäº§è®¢å•号 æˆå“ å®Œå·¥æ•°é‡ å·¥åº æ£€éªŒç»“论 ä¸åˆæ ¼å¤„理 åŽŸæ–™æ‰¹å· ç‰©æ–™ ç‰©æ–™è§„æ ¼ é¢†æ–™æ•°é‡ ä¾›åº”商 åŽŸæ–™æ£€éªŒç»“è®º`;
反向:`原料批号 ç‰©æ–™ ç‰©æ–™è§„æ ¼ å•位 ä¾›åº”商 é‡‡è´­åˆåŒå· é‡‡è´­æ—¥æœŸ åŽŸæ–™æ£€éªŒç»“è®º ç”Ÿäº§è®¢å•号 æˆå“ é¢†ç”¨æ•°é‡ äº§å‡ºæˆå“æ‰¹å·`。
## ä¸‰ã€é¡µé¢å»ºè®®
- **菜单:沿用已有「生产追溯」(menu_id=2294,父级 ç”Ÿäº§ç®¡æŽ§ï¼Œcomponent `productionManagement/productionTraceability/index`),本次不新增菜单。** è‹¥å‰ç«¯è¦ç‹¬ç«‹å…¥å£ï¼Œéœ€å¦å»ºç»„件文件后再加菜单行。
- é¡µé¢å½¢æ€ï¼šé¡¶éƒ¨åˆ‡æ¢ **正向 / åå‘** ä¸¤ä¸ªé¡µç­¾ + æ‰¹å·æœç´¢ä¸‹æ‹‰ï¼ˆbatchOptions)+ è¾“入框 + æŸ¥è¯¢/导出按钮。
- æ­£å‘结果建议用"时间线 + æŠ˜å é¢æ¿":订单卡 â†’ å·¥åºæŠ¥å·¥ï¼ˆå¯å±•开投入物料)→ é¢†æ–™æ‰¹æ¬¡ï¼ˆç‚¹åŽŸæ–™æ‰¹å·è‡ªåŠ¨åˆ‡åˆ°åå‘è¿½æº¯ï¼‰â†’ æ£€éªŒå•(含不合格处理)→ å‡ºåº“流向。
- åå‘结果建议表格:一行一个订单,`finishedBatches` æ¸²æŸ“为可点击标签,点击即跳转正向追溯并回填批号。
- å»ºè®®æä¾› `npsNo` è·³è½¬ç”Ÿäº§è®¢å•详情、`inspectId` è·³è½¬æ£€éªŒå•详情的联动。
## å››ã€è”调自测清单(已用真实数据核对的样例)
- [x] æ­£å‘ `20260826-6-001` â†’ è®¢å• `SC202608231108`、工单 `GD20260823071`(打磨)、领料 `20260801-HDPE HE120-001`、出厂检验"合格"、出库记录 1 æ¡(发货62)
- [x] æ­£å‘多订单/多入库类型:record_type 2 ä¸Ž 6 å‡å¯å‘½ä¸­ï¼ˆ6 ç»æ£€éªŒå•回连报工单)
- [x] åå‘ `20260801-PA6 B100HS-UV 7CN MP25-001` â†’ å‘½ä¸­ 5+ ä¸ªç”Ÿäº§è®¢å•(召回范围),含各订单成品批号如 `20260801-6-011`
- [x] åå‘原料来源:`record_type=10/7` çš„æ‰¹å·å¯å¸¦å‡ºä¾›åº”商/采购合同号/采购日期;`record_type=0`(自定义入库) ä¾›åº”商为空属正常
- [x] æ‰¹å·ä¸‹æ‹‰ï¼šfinished / material ä¸¤ä¸ªæ–¹å‘均可模糊搜索(按批号、产品名称、规格)
- [x] ä¸å­˜åœ¨çš„æ‰¹å·ï¼šforward/reverse è¿”回空数组而非报错
- [ ] å‰ç«¯ï¼šä¸¤ä¸ªæ–¹å‘的导出内容与页面展示一致
- [ ] å‰ç«¯ï¼šæ‰¹å·åœ¨å„视图间跳转(正向↔反向、订单详情、检验单详情)
## äº”、遗留说明
- `orderCreateTime` å£å¾„为 `coalesce(create_time, start_time)`:历史订单 create_time ä¸ºç©ºæ—¶å›žé€€å¼€å·¥æ—¥æœŸï¼Œå‰ç«¯æ— éœ€ç‰¹æ®Šå¤„理。
- è‡ªå®šä¹‰å…¥åº“/出库的批号(record_type 0/1)没有单据来源,其供应商、客户信息无法自动带出,页面显示为空。
- é”€å”®å‡ºåº“的客户名称需按 `stock_out_record.record_id` å…³è”销售单,当前未纳入链路(涉及销售模块改造),二期可加 `customer` å­—段。
- åŠæˆå“å¤šå±‚批号:目前按"订单→报工→投入"平铺展示,未做跨订单的父子批号递归;若客户需要"成品批号→半成品批号→原料批号"多级展开,二期补 `depth` é€’归接口。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é4-BOMÎïÁÏÇåµ¥.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—4:BOM物料清单完善
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€æœ¬æ¬¡æ”¹åŠ¨æ¦‚è§ˆ
原有 `/technologyBom` å·²æ”¯æŒï¼šæ–°å¢ž/修改/删除/复制/导入导出/按规格查询。本次补充:
| èƒ½åŠ› | è¯´æ˜Ž |
|---|---|
| ç‰ˆæœ¬å¯ç”¨/停用 | `technology_bom.status`(1启用 0停用),同一产品规格**仅一个启用版本** |
| BOM齐套分析 | æŒ‰éœ€æ±‚数量逐层展开BOM,与可用库存(数量−锁定量)比对,输出缺口 |
| é½å¥—分析导出 | Excel |
| ç¼ºå£ç”Ÿæˆé‡‡è´­éœ€æ±‚ | æ–°è¡¨ `procurement_demand` + æ–°èœå•「采购需求」(menu_id=2353,见文末菜单说明) |
### ç‰ˆæœ¬è§„则(重要)
- æ–°å¢žBOM:`status` ä¸ä¼ é»˜è®¤ **1(启用)**,并自动停用同产品规格的其它启用版本。
- å¤åˆ¶BOM:新版本号前缀 `FZ`,状态固定 **0(停用/草稿)**,改完结构后再手动启用。
- å¯ç”¨æŸç‰ˆæœ¬ï¼šåŒè§„格其它版本自动停用。
- åˆ—表页建议增加"状态"列(el-tag:启用=success / åœç”¨=info)。
### æ•°æ®åº“升级脚本
```sql
ALTER TABLE technology_bom
    ADD COLUMN status tinyint NOT NULL DEFAULT 1 COMMENT '状态 1启用 0停用' AFTER version;
CREATE TABLE procurement_demand (
  id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  product_model_id bigint DEFAULT NULL COMMENT '产品规格id',
  product_name varchar(255) DEFAULT NULL COMMENT '产品名称(冗余)',
  product_model varchar(255) DEFAULT NULL COMMENT '规格型号(冗余)',
  unit varchar(32) DEFAULT NULL COMMENT '单位(冗余)',
  demand_quantity decimal(18,4) DEFAULT NULL COMMENT '缺口需求量',
  stock_quantity decimal(18,4) DEFAULT NULL COMMENT '生成时可用库存快照',
  source_bom_id bigint DEFAULT NULL COMMENT '来源BOM id',
  source_bom_no varchar(64) DEFAULT NULL COMMENT '来源BOM编号',
  status tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1已转采购 2已取消',
  remark varchar(500) DEFAULT NULL COMMENT '备注',
  create_time datetime DEFAULT CURRENT_TIMESTAMP,
  create_user bigint DEFAULT NULL,
  update_time datetime DEFAULT NULL,
  update_user bigint DEFAULT NULL,
  tenant_id bigint DEFAULT NULL,
  dept_id bigint DEFAULT NULL,
  PRIMARY KEY (id),
  KEY idx_procurement_demand_model (product_model_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购需求-BOM齐套缺口';
```
## äºŒã€/technologyBom æŽ¥å£æ˜Žç»†ï¼ˆå‰ç¼€åŒçŽ°æœ‰ï¼‰
返回体沿用 `R` ç»“构:`{code:200, msg, data}`(成功 code=200)。
### 1. BOM启用/停用(新增)
`PUT /technologyBom/changeStatus` body: `{"id": 56, "status": 1}`
- æˆåŠŸï¼š`{code:200}`;失败:`code:500`("参数错误…"/"BOM不存在")
- status=1 æ—¶åŽç«¯è‡ªåŠ¨åœç”¨åŒè§„æ ¼å…¶å®ƒå¯ç”¨ç‰ˆæœ¬ï¼Œå‰ç«¯åªéœ€åˆ·æ–°åˆ—è¡¨
### 2. BOM齐套分析(新增)
`POST /technologyBom/kitCheck` body: `{"bomId": 56, "demandQty": 100}`
```json
{
  "code": 200,
  "data": [
    {
      "productModelId": 470, "productName": "山核桃", "model": "散装", "unit": "kg",
      "requiredQty": 105.00, "availableQty": 300.00, "shortageQty": 0
    },
    {
      "productModelId": 471, "productName": "食用盐", "model": "500g", "unit": "袋",
      "requiredQty": 500.00, "availableQty": 20.00, "shortageQty": 480.00
    }
  ]
}
```
- æŽ’序:按缺口数量降序(缺的排前面)
- å£å¾„:可用库存 = Î£(库存表数量 âˆ’ è®¢å•锁定量);多层级物料需求量 = ä¸Šçº§éœ€æ±‚ Ã— å•位用量(根节点不计入)
- æ ¡éªŒå¤±è´¥ï¼ˆcode=500):"请传入bomId和需求数量(须大于0)"、"BOM不存在"、"BOM结构为空,请先维护物料清单"、"BOM下无物料节点"
### 3. é½å¥—分析导出(新增)
`POST /technologyBom/kitCheck/export` body åŒ kitCheck,响应为 Excel æµï¼ˆæ–‡ä»¶å"BOM齐套分析"),前端 blob ä¸‹è½½ã€‚
### 4. ç¼ºå£ç”Ÿæˆé‡‡è´­éœ€æ±‚(新增)
`POST /technologyBom/kitCheck/generateDemand` body åŒ kitCheck
- æœ‰ç¼ºå£ï¼š`{code:200, data:"采购需求生成完成:新增 2 æ¡ï¼Œç´¯åŠ åˆå¹¶ 1 æ¡"}`
- æ— ç¼ºå£ï¼š`{code:200, data:"库存齐套,无需生成采购需求"}`
- è§„则:仅 `shortageQty>0` çš„物料入库;同规格已存在**待处理**记录则数量累加并刷新快照;已转采购/已取消的记录不参与合并(再次点击会新建)
### 5. è€æŽ¥å£å“åº”变化
- `GET /technologyBom/listPage`、`GET /technologyBom/getByModel`:行数据新增 `status` å­—段
- `POST /technologyBom/add`:body å¯é€‰ä¼  `status`,不传默认启用
- `PUT /technologyBom/update`:body è‹¥å¸¦ `status:1` åŒæ ·è§¦å‘"同规格互斥停用"
## ä¸‰ã€/procurementDemand é‡‡è´­éœ€æ±‚管理(新增页面)
| æŽ¥å£ | è¯´æ˜Ž |
|---|---|
| `GET /procurementDemand/listPage` | åˆ†é¡µï¼›å‚æ•° `pageNum`/`pageSize`、`productName`(模糊)、`productModel`(模糊)、`status`;返回 `data.records/total`;按 id å€’序 |
| `PUT /procurementDemand/changeStatus?ids=1,2&status=1` | æ‰¹é‡çŠ¶æ€å˜æ›´ï¼›status: 0待处理/1已转采购/2已取消 |
| `DELETE /procurementDemand/del` | body `[id...]`;仅待处理可删,否则 `code:500 "存在非待处理状态的需求,只能删除待处理数据"` |
| `POST /procurementDemand/export` | query å‚数同 listPage,Excel æµï¼ˆæ–‡ä»¶å"采购需求") |
行字段:`id, productModelId, productName, productModel, unit, demandQuantity, stockQuantity, sourceBomId, sourceBomNo, status, remark, createTime, updateTime`
页面建议:列表 + çŠ¶æ€ç­›é€‰ï¼›æ“ä½œåˆ—"标记已转采购/取消/删除";顶部"导出"。"转采购"本期只改状态标记,不自动建采购单(待与采购模块流程确认后二期接)。
**菜单(已加)**:名称「采购需求」,menu_id=2353,父级「采购管理」(2015),路由 `procurementDemand`,component `procurementManagement/procurementDemand/index`,可见角色 æ™®é€š/HR/采购/管理/财务。
BOM æœ¬ä½“沿用已有菜单「BOM」(menu_id=2240,父级 å·¥è‰ºè®¾è®¡ 2291,component `productionManagement/productStructure/index`),本次不新增。
## å››ã€è”调自测清单
- [ ] å¯¹åŒä¸€è§„格新增第二个BOM,第一个自动变停用;列表状态列正确
- [ ] å¤åˆ¶å‡ºçš„BOM为停用态,启用后原启用版本自动停用
- [ ] kitCheck:demandQty=0 / bomId ä¸å­˜åœ¨çš„æŠ¥é”™æç¤ºæ­£å¸¸å±•示
- [ ] é½å¥—结果可用库存数值 = åº“存页(数量−锁定)核对一致
- [ ] generateDemand ä¸¤æ¬¡ç‚¹å‡»ï¼šç¬¬ä¸€æ¬¡æ–°å¢žã€ç¬¬äºŒæ¬¡åŒè§„格数量累加(不重复建行)
- [ ] é‡‡è´­éœ€æ±‚页筛选/分页/删除拦截/状态批量变更/导出
- [ ] å›žå½’:BOM原有 å¢žåˆ æ”¹æŸ¥/导入导出/复制 ä¸å—影响
## äº”、遗留说明
- é½å¥—分析含半成品中间层(所有层级物料都比对库存);若客户只要叶子原料,二期加 `onlyLeaf` å‚数。
- "转采购"不联动生成采购台账单据,仅状态流转,待确认采购侧流程。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é5+2-Éú²ú±¨¹¤¼Æ¼þÓëÉ豸²úÁ¿Í³¼Æ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,111 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—5+2:生产报工(设备/计件单价/工资汇总)与设备产量统计
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€æœ¬æ¬¡æ”¹åŠ¨æ¦‚è§ˆ
| èƒ½åŠ› | è¯´æ˜Ž |
|---|---|
| æŠ¥å·¥å…³è”设备 | `production_product_main` æ–°å¢ž `device_id/device_name`,报工单可选填使用设备 |
| è®¡ä»¶å•价配置 | æ–°è¡¨ `piece_rate_config`(产品规格+工序 å”¯ä¸€ï¼‰+ æ–°èœå•「计件单价配置」(menu_id=2352) |
| å·¥èµ„口径升级 | ç”Ÿäº§æ ¸ç®—明细(wages):**命中计件单价→数量×单价(计件)**,否则回退原"工时定额×工时"口径(计时),返回 `wageMode` æ ‡è¯† |
| è®¡ä»¶å·¥èµ„汇总 | æŒ‰äººå‘˜æ±‡æ€»ï¼šæŠ¥å·¥å•æ•°/生产数量/总工时/计件工资/应发合计,可导出 |
| äº§é‡ç»Ÿè®¡ | æŒ‰ è®¾å¤‡/工序/产品 ä¸‰ç»´åº¦æ±‡æ€»äº§é‡ã€æŠ¥åºŸã€å·¥æ—¶ï¼Œå¯å¯¼å‡º |
### æ•°æ®åº“升级脚本
```sql
ALTER TABLE production_product_main
    ADD COLUMN device_id bigint NULL COMMENT '使用设备id(device_ledger.id)' AFTER user_name,
    ADD COLUMN device_name varchar(100) NULL COMMENT '设备名称(冗余)' AFTER device_id,
    ADD INDEX idx_ppm_device_id (device_id);
CREATE TABLE piece_rate_config (
  id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  product_model_id bigint NOT NULL COMMENT '产品规格id',
  product_model varchar(255) DEFAULT NULL COMMENT '规格型号(冗余)',
  operation_name varchar(100) NOT NULL COMMENT '工序名称',
  unit_price decimal(10,4) NOT NULL COMMENT '计件单价(元/ä»¶)',
  status tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
  remark varchar(500) DEFAULT NULL,
  create_time datetime DEFAULT CURRENT_TIMESTAMP,
  create_user bigint DEFAULT NULL,
  update_time datetime DEFAULT NULL,
  update_user bigint DEFAULT NULL,
  dept_id bigint DEFAULT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_piece_rate_model_op (product_model_id, operation_name),
  KEY idx_piece_rate_op (operation_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计件单价配置(按产品规格+工序)';
```
> æ³¨ï¼šproduct_model å†—余列已通过代码写入,若建表时未含该列请补 `ALTER TABLE piece_rate_config ADD COLUMN product_model varchar(255) NULL AFTER product_model_id;`
### è®¡ä»¶åŒ¹é…è§„则
报工核算行(production_account) çš„ `产品规格 + å·¥åºåç§°(technology_operation_name)` ç²¾ç¡®åŒ¹é… **启用中** çš„计件单价;匹配不到则回退原计时公式。因此工序名称必须与检验/工艺里的工序名一致(建议配置页工序用下拉取自已有核算工序名)。
## äºŒã€/pieceRateConfig è®¡ä»¶å•价配置(新增页面)
| æŽ¥å£ | è¯´æ˜Ž |
|---|---|
| `GET /pieceRateConfig/listPage` | å‚æ•° `pageNum/pageSize`、`operationName`(模糊)、`productModel`(模糊)、`productModelId`、`status` |
| `POST /pieceRateConfig/add` | body: `{productModelId, operationName, unitPrice, remark}`;校验:规格必填、工序必填、单价>0、同规格同工序唯一 |
| `PUT /pieceRateConfig/update` | body å« id,校验同 add |
| `PUT /pieceRateConfig/changeStatus` | body: `{"id":1,"status":0}` |
| `DELETE /pieceRateConfig/batchDelete` | body `[id...]` |
| `GET /pieceRateConfig/export` | query åŒ listPage,Excel æµ"计件单价配置" |
行字段:`id, productModelId, productModel, operationName, unitPrice, status, remark, createTime, updateTime`
**菜单(已加)**:名称「计件单价配置」,menu_id=2352,父级「生产管控」(2058),路由 `pieceRateConfig`,component `productionManagement/pieceRateConfig/index`,可见角色 æ™®é€š/制造。
## ä¸‰ã€æŠ¥å·¥æŽ¥å£å˜åŒ–(/productionProductMain)
- `POST /productionProductMain/addProductMain`、`POST`、`PUT`:body å¯é€‰ä¼  `deviceId`(设备台账id);设备不存在报 `设备不存在,ID=x`;device_name åŽç«¯è‡ªåŠ¨å†—ä½™ã€‚
- `GET /listPage`:行数据新增 `deviceId/deviceName`;筛选参数新增 `deviceId`(精确)、`deviceName`(模糊)。
- æŠ¥å·¥åˆ—表页建议增加"使用设备"列与设备下拉筛选(下拉数据源:`GET /device/ledger/listPage` çŽ°æœ‰è®¾å¤‡å°è´¦æŽ¥å£ï¼‰ã€‚
## å››ã€æ–°å¢žç»Ÿè®¡æŽ¥å£
### 1. è®¡ä»¶å·¥èµ„汇总
`GET /productionProductMain/wageSummary?startDate=2026-09-01&endDate=2026-09-30&userName=å¼ `
```json
{ "code": 200, "data": [
  { "userId": 18, "userName": "张三", "workCount": 12, "quantity": 3600, "workHour": 96, "pieceWage": 1800.00, "wages": 2450.00 }
]}
```
- æŒ‰ `production_account.scheduling_user` åˆ†ç»„ï¼›`pieceWage` ä»…计件部分;`wages`=计件优先+未命中回退计时的合计。
- å¯¼å‡ºï¼š`GET /productionProductMain/wageSummary/export`(同参数,blob ä¸‹è½½"计件工资汇总")。
### 2. äº§é‡ç»Ÿè®¡
`GET /productionProductMain/outputStat?dimension=device&startDate=&endDate=&deviceId=`
- `dimension`:`device`设备(默认)/`operation`工序/`product`产品-规格;非法值报 `统计维度不合法…`
- è¿”回:`[{groupId, groupName, workCount, quantity, scrapQty, workHour}]`,按数量降序;设备未填报工归入"未指定设备"。
- å£å¾„:`quantity`=各工序完工数(`production_account.finished_num`)合计,按工序展开累加;`workCount`=去重报工单数;`workHour`/`scrapQty` æ˜¯**报工单级标量**,按报工单去重后计入——一张报工单录多道工序时不会重复累加(工时归入该单首道工序行)。
- å¯¼å‡ºï¼š`GET /productionProductMain/outputStat/export`(同参数,"产量统计")。
**菜单(已加)**:名称「产量统计」,menu_id=2349,父级「生产管控」(2058),路由 `outputStatistics`,component `productionManagement/outputStatistics/index`,可见角色 æ™®é€š/制造。与已有「工序生产实况」(2296) å†…容有重叠——若前端决定把产量统计做成该页的一个 Tab,请删除 2349 è¿™æ¡èœå•行(`DELETE FROM sys_menu WHERE menu_id=2349` åŠå…¶ sys_role_menu å…³è”)。
### 3. æ ¸ç®—明细(老接口响应变化)
`listProductionDetails`(生产核算页)每行新增:`wageMode`(计件/计时)、`deviceId`、`deviceName`;`wages` è®¡ä»¶ä¼˜å…ˆã€‚
## äº”、联调自测清单
- [ ] è®¡ä»¶å•价页:新增/重复新增拦截/停用后不再生效/导出
- [ ] æŠ¥å·¥æ—¶é€‰è®¾å¤‡ â†’ åˆ—表/详情/导出显示设备;deviceId ç­›é€‰æ­£ç¡®
- [ ] é…ç½®"规格A+炒制=0.5元/ä»¶"后查核算明细:该工序行 wageMode=计件、wages=数量×0.5
- [ ] æœªé…ç½®å•价的工序行:wageMode=计时、wages ä¸Žæ”¹é€ å‰ä¸€è‡´ï¼ˆå›žå½’)
- [ ] wageSummary æ•°å€¼ = æ ¸ç®—明细按人合计
- [ ] outputStat ä¸‰ç»´åº¦åˆ‡æ¢ã€æ—¶é—´ç­›é€‰ã€è®¾å¤‡ç­›é€‰ã€å¯¼å‡º
- [ ] æŠ¥å·¥åŽŸæœ‰ æ–°å¢ž/修改/删除/列表/参数快照 ä¸å—影响
## å…­ã€é—留说明
- è®¡ä»¶å·¥èµ„不自动写入 `staff_salary_detail.piece_salary`(该表为手工工资模块,联动方案需与人事流程确认,二期接入)。
- è®¡ä»¶å•价按工序名称文本匹配(核算表存的是名称快照),改名工序会失配——改名后需同步维护计件配置。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é6-Ô­ÁϼìÑéÈë¿â.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—6:原料检验入库(合格自动入库 + ä¸åˆæ ¼æ ‡è¯†éš”离)
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€æœ¬æ¬¡æ”¹åŠ¨æ¦‚è§ˆ
原料检验(`quality_inspect.inspect_type = 0`)主流程已存在,本次补齐**幂等与数据一致性**关键缺口:
| èƒ½åŠ› | è¯´æ˜Ž |
|---|---|
| åˆæ ¼è‡ªåŠ¨å…¥åº“ | æäº¤æ—¶å¯¹"合格数量>0"自动生成入库记录(原有,保留) |
| é‡å¤æäº¤æ‹¦æˆª | å·²æäº¤ï¼ˆinspect_state=1)的检验单再次 submit æŠ¥ `检验单已提交,请勿重复提交`(新增) |
| å·²æäº¤ç¦æ”¹ | å·²æäº¤çš„æ£€éªŒå•调用 `/quality/inspect/update` æŠ¥ `检验单已提交,不允许修改`(新增) |
| æ•°é‡æ ¡éªŒ | åˆæ ¼æ•°é‡+不合格数量 > æ£€éªŒæ•°é‡æ—¶æŠ¥é”™ï¼ˆæ–°å¢žï¼‰ |
| ä¸åˆæ ¼æ ‡è¯†éš”离 | ä¸åˆæ ¼æ•°é‡>0 è‡ªåŠ¨ç”Ÿæˆä¸åˆæ ¼å¤„ç†å•ï¼ˆåŽŸæœ‰ï¼‰ï¼›å¤„ç†å•**重复处理拦截 + å¤„理结果必传 + äº‹åŠ¡åŒ–**(新增) |
## äºŒã€å…¥åº“/隔离链路(前端需理解的口径)
1. **提交检验** `POST /quality/inspect/submit`(body: `{"id": æ£€éªŒå•id}`)
   - æ ¡éªŒï¼šæ£€éªŒå•存在、未重复提交、checkResult å·²å¡«ã€åˆæ ¼/不合格数量已填、数量之和 â‰¤ æ£€éªŒæ•°é‡ã€‚
   - åˆæ ¼æ•°é‡ > 0 ä¸”非过程检:生成**待审核入库记录**(`stock_in_record`)
     - æœ‰ `purchaseLedgerId`(采购到货质检)→ record_type = **10**(采购-质检-合格入库)
     - å…¶ä½™ â†’ record_type = **6**(质检-合格入库)
     - æ‰¹å·ï¼šä¼˜å…ˆæ²¿ç”¨ä¸Šçº§ç”Ÿäº§/质检批次,否则按 `日期-产品编码-序号` è‡ªåŠ¨ç”Ÿæˆ
   - **注意:提交后库存不会立即增加**,需在"入库记录"页审核通过后才累加 `stock_inventory`(沿用现有审核流,前端无需改动)。
   - ä¸åˆæ ¼æ•°é‡ > 0 â†’ è‡ªåŠ¨ç”Ÿæˆä¸åˆæ ¼å¤„ç†å•ï¼ˆ`quality_unqualified`,inspect_state=0 å¾…处理),不合格现象取参数项拼接。
2. **不合格处理** `POST /quality/qualityUnqualified/deal`(body å« `id`、`dealResult`)
   - åŽŸæ–™ç±»ï¼ˆinspect_type=0)支持处理结果:`报废`(入不合格隔离库存 stock_uninventory,record_type=4)、`让步放行`(入正式库存,record_type=11)。
   - æ–°å¢žæ‹¦æˆªï¼š
     - è®°å½•不存在 â†’ `不合格记录不存在`
     - å·²å¤„理再次提交 â†’ `该不合格记录已处理,请勿重复操作`
     - dealResult æœªä¼  â†’ `请选择处理结果`
   - "退供应商"不在本期联动范围,走现有采购退货模块(见遗留说明)。
## ä¸‰ã€æŽ¥å£æ˜Žç»†ï¼ˆå‰ç¼€åŒçŽ°æœ‰ï¼Œè¿”å›ž R:{code:200,msg,data})
| æŽ¥å£ | å˜åŒ– |
|---|---|
| `POST /quality/inspect/add` | æ— å˜åŒ–(保存草稿,inspect_state=0) |
| `POST /quality/inspect/update` | **新增拦截**:已提交单据返回 code=500 `检验单已提交,不允许修改` |
| `POST /quality/inspect/submit` | **新增拦截**:重复提交 code=500 `检验单已提交,请勿重复提交`;**新增校验** `合格数量与不合格数量之和不能大于检验数量` |
| `POST /quality/inspect/autoSubmit?id=` | æ— å˜åŒ–;已提交时返回 `检验单已提交` |
| `GET /quality/inspect/listPage` | æ— å˜åŒ–,行数据含 `inspectState/checkResult/qualifiedQuantity/unqualifiedQuantity` |
| `POST /quality/qualityUnqualified/deal` | **新增拦截**:重复处理/未传处理结果,事务回滚 |
| `GET /quality/qualityUnqualified/listPage` | æ— å˜åŒ–(inspect_state 0待处理/1已处理) |
## å››ã€å‰ç«¯å»ºè®®
- **菜单:沿用已有「原材料检验」(menu_id=2046,父级 è´¨é‡ç®¡ç† 2040,component `qualityManagement/rawMaterialInspection/index`),本次不新增菜单。**
- æäº¤æŒ‰é’®ç‚¹å‡»åŽç½®ç°/加确认弹窗("提交后将生成入库记录且不可修改");收到 500 æŠ¥é”™ç›´æŽ¥ `message.error(msg)`。
- ç¼–辑页进入时若 `inspectState===1`,表单整体只读、隐藏保存按钮。
- ä¸åˆæ ¼å¤„理弹窗:dealResult ä¸‹æ‹‰å¿…选(原料:报废/让步放行),提交成功后刷新列表。
- æäº¤æˆåŠŸæç¤ºè¯­å»ºè®®ï¼š"提交成功,合格部分已生成入库记录(待审核),不合格部分已生成处理单"。
## äº”、联调自测清单
- [ ] æ–°å¢žåŽŸæ–™æ£€éªŒå• â†’ æäº¤ â†’ å…¥åº“记录页出现 record_type=10/6 çš„待审核记录
- [ ] å…¥åº“记录审核通过 â†’ å®žæ—¶åº“存数量增加(数量与合格数量一致,批号正确)
- [ ] åŒä¸€æ£€éªŒå•重复 submit â†’ æŠ¥é”™"请勿重复提交",库存不重复增加
- [ ] å·²æäº¤å•据调 update â†’ æŠ¥é”™"不允许修改"
- [ ] åˆæ ¼+不合格 > æ£€éªŒæ•°é‡ â†’ submit æŠ¥é”™
- [ ] ä¸åˆæ ¼æ•°é‡>0 â†’ ä¸åˆæ ¼åˆ—表出现待处理记录;处理后库存/隔离区变化正确
- [ ] åŒä¸€ä¸åˆæ ¼è®°å½•重复 deal â†’ æŠ¥é”™"请勿重复操作"
- [ ] è¿‡ç¨‹æ£€ï¼ˆinspect_type=1)提交 â†’ ä¸äº§ç”Ÿå…¥åº“记录(回归)
## å…­ã€é—留说明
- åŽŸæ–™ä¸åˆæ ¼"退供应商"不自动联动采购退货单,仍走采购退货页面人工建单(涉及台账/金额核销,二期与采购侧确认流程后再接)。
- åˆæ ¼å…¥åº“沿用"入库记录审核"环节,如客户要求提交即入账(免审核),需单独确认后放开。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é7-³ÉÆ·¼ìÑé¹ÜÀí.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—7:成品检验管理(合格入库/返修返工/报废)
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€ä¸šåŠ¡é“¾è·¯æ€»è§ˆï¼ˆæˆå“ä¾§ï¼‰
```
生产报工(最后一道工序且该工序 isQuality=1)
   â””─ è‡ªåŠ¨ç”Ÿæˆ"出厂检验单"(quality_inspect.inspect_type=2,自动带出检验标准参数,关联报工单 productMainId)
提交检验单(/quality/inspect/submit)
   â”œâ”€ åˆæ ¼æ•°é‡>0 â†’ ç”Ÿæˆå¾…审核入库记录(record_type=6 è´¨æ£€-合格入库,批号沿用报工入库批次)
   â”‚                â†’ å…¥åº“记录页审核通过后累加成品库存 stock_inventory
   â””─ ä¸åˆæ ¼æ•°é‡>0 â†’ ç”Ÿæˆä¸åˆæ ¼å¤„理单(quality_unqualified,待处理)
不合格处理(/quality/qualityUnqualified/deal)
   â”œâ”€ è¿”ä¿®/返工 â†’ è‡ªåŠ¨åˆ›å»º FG å‰ç¼€è¿”工生产订单(复制原订单工艺路线并逐工序生成工单任务)
   â”œâ”€ æŠ¥åºŸ     â†’ æ•°é‡å…¥ä¸åˆæ ¼éš”离库存(stock_uninventory,record_type=4)
   â””─ è®©æ­¥æ”¾è¡Œ â†’ æ•°é‡å…¥æ­£å¼åˆæ ¼åº“存(record_type=11)
```
过程检验(inspect_type=1,非最后工序的质检工序)同样自动生成检验单,提交时**不产生入库**,仅不合格走处理单。
## äºŒã€æœ¬æ¬¡åŽç«¯æ”¹åЍ
1. **处理结果枚举校验(新增)**:成品/过程类不合格单仅接受 `返修/返工/报废/让步放行`;原料类仅 `报废/让步放行`。不合法返回 code=500 `处理结果不合法,仅支持:…`。
2. **重复处理拦截**(与模块6共用):已处理记录再次 deal â†’ `该不合格记录已处理,请勿重复操作`。
3. **防篡改回写(新增)**:处理更新以库中记录为准,仅回写处理结果/处理人/处理日期;请求体无法再覆盖数量、状态等字段。
4. **处理人/日期自动填充(新增)**:前端不传 `dealName` æ—¶åŽç«¯å–当前登录用户名;不传 `dealTime` æ—¶å–当前时间。
5. **检验单幂等(与模块6共用)**:提交/编辑拦截同样作用于出厂/过程检验单。
## ä¸‰ã€æŽ¥å£æ˜Žç»†ï¼ˆè¿”回 R:{code:200,msg,data})
| æŽ¥å£ | è¯´æ˜Ž |
|---|---|
| `GET /quality/inspect/listPage` | æ£€éªŒå•分页,参数含 `inspectType`(成品页固定传 2) |
| `POST /quality/inspect/submit` | æäº¤ï¼›æŠ¥é”™è§æ¨¡å—6文档 |
| `POST /quality/inspect/update` | å·²æäº¤å•据禁改 |
| `POST /quality/inspect/export` | æŒ‰ inspectType å¯¼å‡ºï¼ˆ2=出厂检验导出) |
| `POST /quality/inspect/down` | ç”Ÿæˆæ£€éªŒæŠ¥å‘Š docx(模板渲染) |
| `GET /quality/qualityUnqualified/listPage` | ä¸åˆæ ¼å•分页(含 inspectType/inspectState ç­›é€‰ï¼‰ |
| `POST /quality/qualityUnqualified/deal` | å¤„理;body: `{"id": xx, "dealResult": "返工"}`,可选 dealName/dealTime |
## å››ã€å‰ç«¯é¡µé¢å»ºè®®
- **菜单:沿用已有「过程检验」(menu_id=2047) /「出厂检验」(menu_id=2048),父级 è´¨é‡ç®¡ç† 2040,本次不新增菜单。** æˆå“æ£€éªŒï¼ˆinspectType=2)复用这两个已有页面,用 `inspectType` åŒºåˆ†ã€‚
- æˆå“æ£€éªŒé¡µï¼šåˆ—表筛选固定 `inspectType=2`;操作列 æäº¤/查看报告;`inspectState=1` æ—¶è¡¨å•只读。
- ä¸åˆæ ¼å¤„理弹窗:成品场景"处理结果"下拉四项(返修/返工/报废/让步放行);选返修/返工时提示"将自动创建返工生产订单(FG单号)"。
- å¤„理成功后建议跳转到生产订单列表(新单号为 FG+日期+序号),便于继续跟踪返工进度。
- æäº¤/处理返回 code=500 æ—¶ç›´æŽ¥å±•示 msg。
## äº”、联调自测清单
- [ ] æœ€åŽä¸€é“质检工序报工 â†’ å‡ºåŽ‚æ£€éªŒåˆ—è¡¨å‡ºçŽ°å¾…æäº¤æ£€éªŒå•ï¼Œå‚æ•°å·²æŒ‰æ£€éªŒæ ‡å‡†å¸¦å‡º
- [ ] æäº¤åˆæ ¼æ•°é‡ â†’ å…¥åº“记录页出现待审核记录(批号=报工批次)→ å®¡æ ¸åŽæˆå“åº“存增加
- [ ] ä¸åˆæ ¼æäº¤ â†’ å¤„理单生成;分别走 è¿”ä¿®/返工 â†’ ç”Ÿæˆ FG ç”Ÿäº§è®¢å•+工序工单;报废 â†’ éš”离库存增加;让步放行 â†’ åˆæ ¼åº“存增加
- [ ] åŽŸæ–™ä¸åˆæ ¼å•ä¼  `返工` â†’ æŠ¥é”™"处理结果不合法"
- [ ] åŒä¸€ä¸åˆæ ¼å•重复处理 â†’ æŠ¥é”™"请勿重复操作"
- [ ] å·²å¤„理单再查详情 â†’ dealResult/dealName/dealTime æ­£ç¡®è½åº“
- [ ] è¿‡ç¨‹æ£€ï¼ˆtype=1)提交 â†’ ä¸äº§ç”Ÿå…¥åº“记录,不合格处理正常
## å…­ã€é—留说明
- è¿”工订单与原检验单的关联仅能通过工序/产品间接追踪(不合格单未存返工订单号),二期可在 quality_unqualified åŠ  `rework_order_id` å­—段回写。
- è®©æ­¥æ”¾è¡Œä¸åŒºåˆ†æ˜¯å¦æ”¶è´¹/扣供应商款,与采购台账核销待采购侧确认。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é8-ʵʱ¿â´æ¹ÜÀíÅ̵ãÓëÔ¤¾¯.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—8:实时库存管理(盘点 + å·®å¼‚分析 + é¢„警)
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€æœ¬æ¬¡æ”¹åŠ¨æ¦‚è§ˆ
| èƒ½åŠ› | è¯´æ˜Ž |
|---|---|
| åº“存盘点 | æ–°è¡¨ `stock_check_main`/`stock_check_detail` + æ–°èœå•「库存盘点」(menu_id=2354) |
| ç›˜ç‚¹æµç¨‹ | å‘èµ·(快照账面) â†’ å½•入实盘 â†’ å®Œæˆ(差异自动生成待审核调账单据)/取消 |
| å·®å¼‚分析 | ç›˜ç‚¹å•差异列表 + å¯¼å‡º |
| åº“存预警 | å¤ç”¨åº“存分页新增 `warnPage`/`warnExport`(可用数量=数量−锁定 â‰¤ é¢„警值 ä¸” é¢„警值>0) |
### æ•°æ®åº“升级脚本
```sql
CREATE TABLE stock_check_main (
  id bigint NOT NULL AUTO_INCREMENT,
  check_no varchar(64) NOT NULL COMMENT '盘点单号',
  check_name varchar(255) DEFAULT NULL COMMENT '盘点名称',
  check_date date DEFAULT NULL COMMENT '盘点日期',
  checker varchar(64) DEFAULT NULL COMMENT '盘点人',
  status tinyint NOT NULL DEFAULT 0 COMMENT '0盘点中 1已完成 2已取消',
  total_kinds int DEFAULT 0 COMMENT '盘点种类数',
  diff_kinds int DEFAULT 0 COMMENT '差异种类数',
  remark varchar(500) DEFAULT NULL,
  create_time datetime DEFAULT CURRENT_TIMESTAMP,
  create_user bigint DEFAULT NULL, update_time datetime DEFAULT NULL,
  update_user bigint DEFAULT NULL, dept_id bigint DEFAULT NULL,
  PRIMARY KEY (id), UNIQUE KEY uk_stock_check_no (check_no), KEY idx_stock_check_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存盘点主表';
CREATE TABLE stock_check_detail (
  id bigint NOT NULL AUTO_INCREMENT,
  main_id bigint NOT NULL COMMENT '盘点主表id',
  inventory_id bigint DEFAULT NULL COMMENT '库存行id',
  product_model_id bigint NOT NULL,
  product_name varchar(255), product_model varchar(255), unit varchar(32),
  batch_no varchar(64) DEFAULT NULL COMMENT '批号',
  book_qty decimal(18,4) COMMENT '账面数量快照',
  actual_qty decimal(18,4) COMMENT '实盘数量',
  diff_qty decimal(18,4) COMMENT '差异=实盘-账面',
  adjusted tinyint NOT NULL DEFAULT 0 COMMENT '0未调账 1已生成调账单据',
  remark varchar(500),
  create_time datetime DEFAULT CURRENT_TIMESTAMP,
  create_user bigint DEFAULT NULL, update_time datetime DEFAULT NULL,
  update_user bigint DEFAULT NULL, dept_id bigint DEFAULT NULL,
  PRIMARY KEY (id), KEY idx_check_detail_main (main_id), KEY idx_check_detail_model (product_model_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存盘点明细表';
```
## äºŒã€/stockCheck ç›˜ç‚¹æŽ¥å£ï¼ˆè¿”回 R:{code,msg,data})
| æŽ¥å£ | è¯´æ˜Ž |
|---|---|
| `POST /stockCheck/start` | body `{checkName, checkDate?, checker?, remark?, productModelIds?}`;不传规格=全部库存行快照;成功返回新盘点单 id;同规格存在盘点中单据时报错并列出冲突规格 |
| `GET /stockCheck/listPage` | å‚æ•° `checkNo`/`checkName`(模糊)、`status`、`beginTime`/`endTime`(yyyy-MM-dd HH:mm:ss) |
| `GET /stockCheck/{id}` | ä¸»è¡¨è¯¦æƒ… |
| `GET /stockCheck/detailPage?mainId=&onlyDiff=` | æ˜Žç»†åˆ†é¡µï¼›onlyDiff=true åªçœ‹å·²å½•入且有差异的行 |
| `PUT /stockCheck/entry` | body æ•°ç»„ `[{id, actualQty, remark?}]`;actualQty>=0;盘点结束后录入报错 |
| `POST /stockCheck/finish` | body `{id}`;未录满报错;差异行生成待审核出入库记录(盘盈→入库自定义、盘亏→出库自定义,备注"盘点调账 PD单号");返回提示语含差异条数 |
| `POST /stockCheck/cancel` | body `{id}`;仅盘点中可取消 |
| `GET /stockCheck/diffList?mainId=` | å·®å¼‚分析列表 |
| `GET /stockCheck/diffList/export?mainId=` / `GET /stockCheck/detailExport?mainId=` / `GET /stockCheck/export` | ä¸‰ä¸ª Excel æµ |
明细行字段:`id, mainId, productModelId, productName, productModel, unit, batchNo, bookQty, actualQty, diffQty, adjusted, remark`
**调账生效口径**:finish åŽå·®å¼‚以"待审核出入库记录"形式存在,需在入库记录/出库记录页审核通过后 `stock_inventory` æ‰å˜åŠ¨ï¼ˆä¸Žå…¨ç³»ç»Ÿåº“å­˜å®¡æ ¸æµä¸€è‡´ï¼‰ã€‚
## ä¸‰ã€åº“存预警(/stockInventory)
- `GET /stockInventory/warnPage`:参数同现有库存分页(`productName` æ¨¡ç³Š + åˆ†é¡µï¼‰ï¼›ä»…返回预警行;行字段含 `qualitity/lockedQuantity/warnNum`,`un_locked_quantity` ä¸ºå¯ç”¨æ•°é‡ã€‚
- `GET /stockInventory/warnExport`:同参数,Excel"库存预警"。
- çŽ°æœ‰ `pagestockInventory` ä¹Ÿæ”¯æŒä¼  `warnOnly=true`(与 warnPage ç­‰æ•ˆï¼Œä¸€èˆ¬ç”¨æ–°æŽ¥å£ï¼‰ã€‚
## å››ã€é¡µé¢å»ºè®®
- **菜单(已加)**:名称「库存盘点」,menu_id=2354,父级「仓储物流」(2029),路由 `stockCheck`,component `inventoryManagement/stockCheck/index`,可见角色 æ™®é€š/HR/制造/库管。盘点详情页可直接复用「库存管理」(2032) çš„æ—¢æœ‰é¡µé¢è·¯ç”±ï¼ˆ`inventoryManagement/stockManagement/index`),建议做成该页的一个子路由或弹窗。预警页无独立菜单,挂在「库存管理」页内。
- ç›˜ç‚¹åˆ—表页:状态列(el-tag:盘点中=warning/已完成=success/已取消=info);操作列 è¯¦æƒ…/完成/取消。
- ç›˜ç‚¹è¯¦æƒ…页:明细表格"账面数(只读)、实盘数(输入框)、差异(前端可即时计算=实盘−账面)";支持表格逐行保存或底部"批量录入"调 entry;onlyDiff å¼€å…³ï¼›å®ŒæˆæŒ‰é’®åŠ äºŒæ¬¡ç¡®è®¤ï¼ˆ"差异将生成调账单据,审核通过后账面生效")。
- é¢„警页:可用数量 â‰¤ é¢„警值 çº¢è‰²é«˜äº®ï¼›é¢„警值维护入口沿用库存编辑(warnNum å­—段)。
## äº”、联调自测清单
- [ ] å‘起盘点(全部/指定规格)→ æ˜Žç»†ä¸Žåº“存行一致(账面快照)
- [ ] åŒè§„格重复发起 â†’ æŠ¥é”™åˆ—出冲突规格
- [ ] å½•入实盘(多行一次提交)→ diffQty è½åº“正确;结束后再录入被拦截
- [ ] finish:未录满拦截;盘盈/盘亏分别出现在入库/出库记录页(待审核,备注含盘点单号)→ å®¡æ ¸åŽåº“存增减正确
- [ ] å·®å¼‚分析列表/三个导出
- [ ] warnPage:调整某行 warnNum åŽé¢„警结果正确;可用数量=数量−锁定口径与列表一致
- [ ] å›žå½’:库存原分页/联合库存/冻结解冻不受影响
## å…­ã€é—留说明
- ç›˜äºè°ƒè´¦èµ°å‡ºåº“"可用库存"校验(锁定+待审核占用),若盘点期间出库超卖会拦截,需先处理出库单据再完成盘点。
- æœªåš"盘点期间锁定库存出入库"强管控(二期可加盘点锁)。
docs/ǰ¶ËÁªµ÷Îĵµ-Ä£¿é9-²úƷά»¤.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,119 @@
# å‰ç«¯è”调文档 â€” æ¨¡å—9:产品维护
> åˆ†æ”¯ï¼šproduct-inventory-management-after-jdk25(客户库 product-inventory-management-qysj)
> åŽç«¯å®Œæˆæ—¥æœŸï¼š2026-09-10 状态:后端已实现并编译通过,待前端联调
## ä¸€ã€æœ¬æ¬¡æ”¹åŠ¨æ¦‚è§ˆ
| å˜æ›´ç‚¹ | è¯´æ˜Ž |
|---|---|
| äº§å“è§„格新增"条形码"字段 | `product_model.barcode`(varchar 64,可空,全局唯一) |
| æ–°å¢žæ¡ç ç²¾ç¡®æŸ¥è¯¢æŽ¥å£ | ä¾›é‡‡è´­/销售/库存页扫码快速选商品 |
| æ‰¹é‡å¯¼å…¥æ”¯æŒæ¡ç åˆ— | æ¨¡æ¿æ–°å¢ž"条形码"列,含文件内/库内查重 |
| è§„格分页查询支持条码筛选 | `pageModel` æ–°å¢ž `barcode` æ¨¡ç³ŠæŸ¥è¯¢å‚æ•° |
| äº§å“åˆ é™¤æ ¡éªŒåŠ å¼º | æ–°å¢ž"存在子分类/存在库存记录"两类拦截 |
### æ•°æ®åº“升级脚本(其他环境部署需执行)
```sql
ALTER TABLE product_model
    ADD COLUMN barcode varchar(64) NULL DEFAULT NULL COMMENT '条形码' AFTER product_code,
    ADD INDEX idx_product_model_barcode (barcode);
```
## äºŒã€æŽ¥å£æ˜Žç»†ï¼ˆ**菜单:沿用已有「产品维护」,menu_id=2023,父级 åŸºç¡€æ•°æ® 2000,component `basicData/product/index`,本次不新增菜单**)
基础路径前缀与现有一致(本地 `http://127.0.0.1:7005`),鉴权沿用 Authorization token,无新增权限标识(该 Controller åŽŸæœ¬æœªæŒ‚ @PreAuthorize)。
### 1. æ–°å¢ž/编辑产品规格(扩展)
`POST /basic/product/addOrEditProductModel`
请求体在原字段基础上**新增 `barcode`(选填)**:
```json
{
  "id": null,
  "productId": 12,
  "model": "500g/袋",
  "productCode": "CP0001",
  "unit": "袋",
  "barcode": "6901234567890"
}
```
校验规则(后端返回 error msg):
- `条形码6901234567890已经存在`(barcode å…¨å±€æŸ¥é‡ï¼Œç¼–辑时排除自身;不传/空串不校验)
- åŽŸæœ‰è§„åˆ™ä¸å˜ï¼šåž‹å·+产品编码组合唯一
### 2. æ¡ç ç²¾ç¡®æŸ¥è¯¢ï¼ˆæ–°å¢žï¼‰
`GET /basic/product/model/byBarcode?barcode=6901234567890`
成功:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "id": 88, "productId": 12, "productName": "山核桃仁",
    "model": "500g/袋", "productCode": "CP0001", "unit": "袋",
    "barcode": "6901234567890"
  }
}
```
未匹配:`code=500`,`msg="条码 xxx æœªåŒ¹é…åˆ°äº§å“è§„æ ¼"`。
适用场景:扫码枪回车后直接回填商品(精确匹配,非模糊)。
### 3. è§„格分页查询(扩展)
`GET /basic/product/pageModel`
- æ–°å¢žæŸ¥è¯¢å‚数:`barcode`(模糊匹配)
- å“åº”行对象新增字段:`barcode`;同时修复了 resultMap,`productCode` çŽ°åœ¨ç¨³å®šè¿”å›ž
- å…¶ä½™å‚数不变:`model`、`productName`、`topProductParentId`
### 4. å¯¼å…¥æ¨¡æ¿ä¸‹è½½ï¼ˆæ‰©å±•)
`GET /basic/product/export`
模板列变更为:`产品编码 | è§„格型号 | å•位 | æ¡å½¢ç `。条形码列**选填**。
### 5. æ‰¹é‡å¯¼å…¥ï¼ˆæ‰©å±•)
`POST /basic/product/import`(multipart `file` + query `productId`,用法不变)
新增校验(整体失败并提示行号,行号=Excel å®žé™…行):
- `第 N è¡Œå¯¼å…¥å¤±è´¥: æ¡å½¢ç  xxx åœ¨æ–‡ä»¶ä¸­é‡å¤`
- `第 N è¡Œå¯¼å…¥å¤±è´¥: æ¡å½¢ç  xxx å·²å­˜åœ¨`
原有行为保留:编码/型号/单位为空报错;`规格型号` å·²å­˜åœ¨äºŽè¯¥äº§å“ä¸‹åˆ™è·³è¿‡å¹¶è®¡å…¥"跳过已存在数据 N æ¡"。
### 6. åˆ é™¤äº§å“ï¼ˆæ ¡éªŒåŠ å¼ºï¼‰
`DELETE /basic/product/delProduct`(body: `[id1,id2]`,用法不变)
新增两类拦截(原来只校验销售/采购记录):
- `该产品下存在子分类,请先删除子分类`
- `该产品规格已存在库存记录,不能删除`
建议前端把 error msg ç›´æŽ¥ toast ç»™ç”¨æˆ·ï¼Œæ— éœ€é¢„检。
### 7. å—影响的老接口(仅响应体加字段)
`GET /basic/product/modelList`、`GET /basic/product/modelListPage`:ProductModel åºåˆ—化后多出 `barcode` å­—段(未填时为 null),前端列表/表单需补一列展示。
## ä¸‰ã€è”调自测清单(前端按此走查)
- [ ] è§„格新增不填条码可保存;填重复条码提示"已经存在"
- [ ] ç¼–辑已有规格(条码留空→改填→再清空)三种情况均正常
- [ ] pageModel æŒ‰æ¡ç æ¨¡ç³Šæœç´¢ç”Ÿæ•ˆ
- [ ] byBarcode æ‰«ç ç²¾ç¡®å‘½ä¸­ï¼Œè¿”回含 productName
- [ ] ä¸‹è½½æ–°æ¨¡æ¿å«"条形码"列;导入:文件内重复条码 / ä¸Žåº“内重复 / æ­£å¸¸æ¡ç  ä¸‰ç§ç”¨ä¾‹
- [ ] åˆ é™¤å«å­åˆ†ç±»çš„产品、删除规格已有库存的产品,均被拦截并提示
- [ ] åˆ é™¤æ— å¼•用产品仍正常(回归)
## å››ã€é—留说明
- æ¡ç å”¯ä¸€æ€§ä¸ºå…¨å±€ï¼ˆä¸é™ç§Ÿæˆ·ï¼‰ï¼Œä¸ŽçŽ°æœ‰"型号+编码"查重口径一致(`product_model` åœ¨ç§Ÿæˆ·æ‹¦æˆªå¿½ç•¥è¡¨ä¸­ï¼‰ã€‚
- æœªåŠ å¯ç”¨/停用状态字段(需求表未要求),如后续要"下架商品"再说。
src/main/java/com/ruoyi/basic/controller/ProductController.java
@@ -121,6 +121,22 @@
    }
    /**
     * æ ¹æ®æ¡ç ç²¾ç¡®æŸ¥è¯¢äº§å“è§„格(采购/销售/库存扫码选商品)
     */
    @Operation(summary = "根据条码查询产品规格")
    @GetMapping("/model/byBarcode")
    public AjaxResult selectByBarcode(@RequestParam String barcode) {
        if (barcode == null || barcode.trim().isEmpty()) {
            return AjaxResult.error("请传入条码");
        }
        ProductModel productModel = productModelService.selectByBarcode(barcode);
        if (productModel == null) {
            return AjaxResult.error("条码 " + barcode + " æœªåŒ¹é…åˆ°äº§å“è§„æ ¼");
        }
        return AjaxResult.success(productModel);
    }
    /**
     * å¯¼å…¥äº§å“
     */
    @PostMapping("/import")
src/main/java/com/ruoyi/basic/dto/ProductModelExportDto.java
@@ -24,4 +24,7 @@
    @Excel(name = "单位")
    private String unit;
    @Excel(name = "条形码")
    private String barcode;
}
src/main/java/com/ruoyi/basic/pojo/ProductModel.java
@@ -40,6 +40,10 @@
    @Excel(name = "产品编码")
    private String productCode;
    @Schema(description = "条形码")
    @Excel(name = "条形码")
    private String barcode;
    /**
     * å•位
     */
src/main/java/com/ruoyi/basic/service/IProductModelService.java
@@ -35,4 +35,9 @@
    IPage<ProductModel> modelListPage(Page page , ProductDto productDto);
    AjaxResult importProductModel(MultipartFile file, Integer productId);
    /**
     * æ ¹æ®æ¡ç æŸ¥è¯¢äº§å“è§„格(含产品名称)
     */
    ProductModel selectByBarcode(String barcode);
}
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java
@@ -30,6 +30,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@@ -53,9 +54,12 @@
    public int addOrEditProductModel(ProductModelDto productModelDto) {
        String model = StringUtils.trim(productModelDto.getModel());
        String productCode = StringUtils.trim(productModelDto.getProductCode());
        String barcode = StringUtils.trim(productModelDto.getBarcode());
        productModelDto.setModel(model);
        productModelDto.setProductCode(productCode);
        productModelDto.setBarcode(barcode);
        checkModelAndProductCodeUnique(model, productCode, productModelDto.getId());
        checkBarcodeUnique(barcode, productModelDto.getId());
        if (productModelDto.getId() == null) {
            ProductModel productModel = new ProductModel();
@@ -78,6 +82,19 @@
        ProductModel duplicateProductModel = productModelMapper.selectOne(queryWrapper);
        if (duplicateProductModel != null) {
            throw new ServiceException("对应的型号" + model + "的产品编码" + productCode + "已经存在");
        }
    }
    private void checkBarcodeUnique(String barcode, Long currentId) {
        if (StringUtils.isEmpty(barcode)) {
            return;
        }
        LambdaQueryWrapper<ProductModel> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ProductModel::getBarcode, barcode)
                .ne(currentId != null, ProductModel::getId, currentId)
                .last("limit 1");
        if (productModelMapper.selectOne(queryWrapper) != null) {
            throw new ServiceException("条形码" + barcode + "已经存在");
        }
    }
@@ -146,6 +163,17 @@
            List<ProductModel> existingModels = list(new LambdaQueryWrapper<ProductModel>().eq(ProductModel::getProductId, productId));
            Set<String> existingModelNames = existingModels.stream().map(ProductModel::getModel).collect(Collectors.toSet());
            //  å·²å­˜åœ¨äºŽæ•°æ®åº“中的条码(全局查重)
            List<String> importBarcodes = productModelList.stream()
                    .map(item -> StringUtils.trim(item.getBarcode()))
                    .filter(StringUtils::isNotEmpty)
                    .distinct()
                    .collect(Collectors.toList());
            Set<String> dbBarcodeSet = importBarcodes.isEmpty() ? new HashSet<>()
                    : list(new LambdaQueryWrapper<ProductModel>().in(ProductModel::getBarcode, importBarcodes))
                    .stream().map(ProductModel::getBarcode).collect(Collectors.toSet());
            Set<String> fileBarcodes = new HashSet<>();
            List<ProductModel> waitToSaveList = new ArrayList<>();
            int skipCount = 0;
@@ -161,6 +189,16 @@
                }
                if (StringUtils.isEmpty(item.getUnit())) {
                    return AjaxResult.error("第 " + rowNum + " è¡Œå¯¼å…¥å¤±è´¥: [单位] ä¸èƒ½ä¸ºç©º");
                }
                item.setBarcode(StringUtils.trim(item.getBarcode()));
                if (StringUtils.isNotEmpty(item.getBarcode())) {
                    if (!fileBarcodes.add(item.getBarcode())) {
                        return AjaxResult.error("第 " + rowNum + " è¡Œå¯¼å…¥å¤±è´¥: æ¡å½¢ç  " + item.getBarcode() + " åœ¨æ–‡ä»¶ä¸­é‡å¤");
                    }
                    if (dbBarcodeSet.contains(item.getBarcode())) {
                        return AjaxResult.error("第 " + rowNum + " è¡Œå¯¼å…¥å¤±è´¥: æ¡å½¢ç  " + item.getBarcode() + " å·²å­˜åœ¨");
                    }
                }
                //  åŽ»é‡,如果已包含该型号,则跳过
@@ -189,4 +227,22 @@
            throw new ServiceException("导入失败");
        }
    }
    @Override
    public ProductModel selectByBarcode(String barcode) {
        if (StringUtils.isEmpty(barcode)) {
            return null;
        }
        LambdaQueryWrapper<ProductModel> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ProductModel::getBarcode, StringUtils.trim(barcode))
                .last("limit 1");
        ProductModel productModel = productModelMapper.selectOne(queryWrapper);
        if (productModel != null && productModel.getProductId() != null) {
            Product product = productMapper.selectById(productModel.getProductId());
            if (product != null) {
                productModel.setProductName(product.getProductName());
            }
        }
        return productModel;
    }
}
src/main/java/com/ruoyi/basic/service/impl/ProductServiceImpl.java
@@ -13,14 +13,18 @@
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.basic.service.IProductService;
import com.ruoyi.basic.vo.ProductModelVo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Service
@AllArgsConstructor
@@ -29,6 +33,8 @@
    private ProductMapper productMapper;
    private ProductModelMapper productModelMapper;
    private StockInventoryMapper stockInventoryMapper;
    @Override
    public List<ProductTreeDto> selectProductList(ProductDto productDto) {
@@ -131,15 +137,31 @@
    @Override
    public int delProductByIds(Long[] ids) {
        // 1. åˆ é™¤å­è¡¨ product_model ä¸­å…³è”的数据
        List<Long> idList = Arrays.asList(ids);
        // 1. å­˜åœ¨å­åˆ†ç±»æ—¶ä¸å…è®¸åˆ é™¤ï¼Œé¿å…æ ‘结构出现孤儿节点
        LambdaQueryWrapper<Product> childWrapper = new LambdaQueryWrapper<>();
        childWrapper.in(Product::getParentId, idList);
        if (productMapper.selectCount(childWrapper) > 0) {
            throw new ServiceException("该产品下存在子分类,请先删除子分类");
        }
        // 2. è§„格已被库存记录引用时不允许删除,避免历史数据断链
        LambdaQueryWrapper<ProductModel> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.in(ProductModel::getProductId, ids);
        productModelMapper.delete(queryWrapper);
        queryWrapper.in(ProductModel::getProductId, idList);
        List<ProductModel> models = productModelMapper.selectList(queryWrapper);
        if (!models.isEmpty()) {
            List<Long> modelIds = models.stream().map(ProductModel::getId).collect(Collectors.toList());
            LambdaQueryWrapper<StockInventory> stockWrapper = new LambdaQueryWrapper<>();
            stockWrapper.in(StockInventory::getProductModelId, modelIds);
            if (stockInventoryMapper.selectCount(stockWrapper) > 0) {
                throw new ServiceException("该产品规格已存在库存记录,不能删除");
            }
            productModelMapper.delete(queryWrapper);
        }
        // 2. åˆ é™¤ä¸»è¡¨ product æ•°æ®
        int deleteCount = productMapper.deleteBatchIds(Arrays.asList(ids));
        return deleteCount;
        // 3. åˆ é™¤ä¸»è¡¨ product æ•°æ®
        return productMapper.deleteBatchIds(idList);
    }
}
src/main/java/com/ruoyi/cost/bean/vo/CostEnergyVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.ruoyi.cost.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "CostEnergyVo", description = "成本核算-区间能耗汇总")
public class CostEnergyVo {
    @Schema(description = "用电量(kWh)")
    private BigDecimal kwh;
    @Schema(description = "电费金额(设备用电量×对应日期电价)")
    private BigDecimal energyCost;
}
src/main/java/com/ruoyi/cost/bean/vo/CostLaborVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package com.ruoyi.cost.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "CostLaborVo", description = "成本核算-订单人工成本")
public class CostLaborVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "人工成本")
    private BigDecimal laborCost;
    @Schema(description = "核算记录数")
    private Integer accountRows;
    @Schema(description = "工时为0且无计件单价的核算记录数(无工资标准)")
    private Integer noRateRows;
}
src/main/java/com/ruoyi/cost/bean/vo/CostMaterialInputVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package com.ruoyi.cost.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "CostMaterialInputVo", description = "成本核算-订单物料实际投入")
public class CostMaterialInputVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Schema(description = "投入数量")
    private BigDecimal inputQty;
}
src/main/java/com/ruoyi/cost/bean/vo/CostMaterialPriceVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package com.ruoyi.cost.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "CostMaterialPriceVo", description = "成本核算-物料单价(多来源,按优先级取首条)")
public class CostMaterialPriceVo {
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Schema(description = "单价")
    private BigDecimal price;
    @Schema(description = "价格来源")
    private String priceSource;
    @Schema(description = "优先级,数值越小越优先")
    private Integer priority;
}
src/main/java/com/ruoyi/cost/bean/vo/CostMissingPriceVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package com.ruoyi.cost.bean.vo;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "CostMissingPriceVo", description = "缺采购单价的消耗物料清单")
public class CostMissingPriceVo {
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Excel(name = "物料名称")
    @Schema(description = "物料名称")
    private String materialName;
    @Excel(name = "物料规格")
    @Schema(description = "物料规格")
    private String materialModel;
    @Excel(name = "单位")
    @Schema(description = "单位")
    private String unit;
    @Excel(name = "消耗数量")
    @Schema(description = "筛选范围内投入合计")
    private BigDecimal inputQty;
    @Excel(name = "涉及订单数")
    @Schema(description = "使用该物料的生产订单数")
    private Integer orderCount;
    @Excel(name = "最近投入时间", width = 22, dateFormat = "yyyy-MM-dd HH:mm:ss")
    @Schema(description = "最近一次投入时间")
    private LocalDateTime lastInputTime;
}
src/main/java/com/ruoyi/cost/bean/vo/CostOrderVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package com.ruoyi.cost.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "CostOrderVo", description = "成本核算-生产订单基础行")
public class CostOrderVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "生产订单号")
    private String npsNo;
    @Schema(description = "成品规格id")
    private Long productModelId;
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Schema(description = "成品名称")
    private String productName;
    @Schema(description = "成品规格")
    private String productModel;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "完工数量")
    private BigDecimal completeQty;
    @Schema(description = "计划数量")
    private BigDecimal planQty;
    @Schema(description = "订单创建时间")
    private LocalDateTime createTime;
}
src/main/java/com/ruoyi/cost/bean/vo/CostSalePriceVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.ruoyi.cost.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "CostSalePriceVo", description = "成本核算-订单关联销售单价")
public class CostSalePriceVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "含税销售单价")
    private BigDecimal salePrice;
}
src/main/java/com/ruoyi/cost/bean/vo/CostStatVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package com.ruoyi.cost.bean.vo;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
@Schema(name = "CostStatVo", description = "成本核算(材料+人工+能耗)")
public class CostStatVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Excel(name = "生产订单号")
    @Schema(description = "生产订单号")
    private String npsNo;
    @Excel(name = "成品")
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Excel(name = "单位")
    @Schema(description = "单位")
    private String unit;
    @Excel(name = "订单日期", width = 20, dateFormat = "yyyy-MM-dd")
    @Schema(description = "订单创建日期")
    private LocalDate orderDate;
    @Excel(name = "产量")
    @Schema(description = "产量:完工数量,未完工回退计划数量")
    private BigDecimal outputQty;
    @Excel(name = "材料成本")
    @Schema(description = "材料成本=Σ(物料投入量×采购单价)")
    private BigDecimal materialCost;
    @Excel(name = "人工成本")
    @Schema(description = "人工成本=计件/计时工资合计")
    private BigDecimal laborCost;
    @Excel(name = "能耗成本")
    @Schema(description = "能耗成本=区间电费按产量占比分摊")
    private BigDecimal energyCost;
    @Excel(name = "总成本")
    @Schema(description = "总成本=材料+人工+能耗")
    private BigDecimal totalCost;
    @Excel(name = "单位成本")
    @Schema(description = "单位成本=总成本/产量")
    private BigDecimal unitCost;
    @Excel(name = "销售单价")
    @Schema(description = "关联销售单含税单价")
    private BigDecimal salePrice;
    @Excel(name = "毛利")
    @Schema(description = "毛利=(销售单价-单位成本)×产量")
    private BigDecimal grossProfit;
    @Excel(name = "毛利率%")
    @Schema(description = "毛利率=毛利/(销售单价×产量)×100")
    private BigDecimal grossProfitRate;
    @Excel(name = "价格覆盖率%")
    @Schema(description = "有采购价的物料消耗量占比,低于100说明有缺价物料")
    private BigDecimal priceCoverageRate;
    @Excel(name = "缺价物料数")
    @Schema(description = "投入但无采购单价的物料种类数")
    private Integer missingPriceKinds;
    @Excel(name = "无工资标准记录")
    @Schema(description = "工时为0且无计件单价的报工核算记录数")
    private Integer noRateRows;
}
src/main/java/com/ruoyi/cost/bean/vo/CostSummaryVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package com.ruoyi.cost.bean.vo;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "CostSummaryVo", description = "成本构成汇总(按月/按成品)")
public class CostSummaryVo {
    @Excel(name = "分组")
    @Schema(description = "分组键:月份 yyyy-MM æˆ–成品名称/规格")
    private String groupKey;
    @Excel(name = "订单数")
    @Schema(description = "订单数")
    private Integer orderCount;
    @Excel(name = "产量")
    @Schema(description = "产量合计")
    private BigDecimal outputQty;
    @Excel(name = "材料成本")
    @Schema(description = "材料成本合计")
    private BigDecimal materialCost;
    @Excel(name = "人工成本")
    @Schema(description = "人工成本合计")
    private BigDecimal laborCost;
    @Excel(name = "能耗成本")
    @Schema(description = "能耗成本合计")
    private BigDecimal energyCost;
    @Excel(name = "总成本")
    @Schema(description = "总成本合计")
    private BigDecimal totalCost;
    @Excel(name = "平均单位成本")
    @Schema(description = "总成本/产量")
    private BigDecimal unitCost;
    @Excel(name = "材料占比%")
    @Schema(description = "材料成本/总成本×100")
    private BigDecimal materialRate;
    @Excel(name = "人工占比%")
    @Schema(description = "人工成本/总成本×100")
    private BigDecimal laborRate;
    @Excel(name = "能耗占比%")
    @Schema(description = "能耗成本/总成本×100")
    private BigDecimal energyRate;
    @Excel(name = "毛利")
    @Schema(description = "毛利合计")
    private BigDecimal grossProfit;
    @Excel(name = "毛利率%")
    @Schema(description = "毛利/销售额×100")
    private BigDecimal grossProfitRate;
}
src/main/java/com/ruoyi/cost/controller/CostAccountingController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
package com.ruoyi.cost.controller;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.cost.bean.vo.CostMissingPriceVo;
import com.ruoyi.cost.bean.vo.CostStatVo;
import com.ruoyi.cost.bean.vo.CostSummaryVo;
import com.ruoyi.cost.service.CostAccountingService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/costAccounting")
@RequiredArgsConstructor
@Tag(name = "成本核算分析")
public class CostAccountingController {
    private final CostAccountingService costAccountingService;
    @GetMapping("/stat")
    @Operation(summary = "成本核算明细(材料+人工+能耗,按生产订单)")
    public R<List<CostStatVo>> stat(
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
            @RequestParam(required = false) String npsNo,
            @RequestParam(required = false) String productName) {
        return R.ok(costAccountingService.stat(startDate, endDate, npsNo, productName));
    }
    @GetMapping("/summary")
    @Operation(summary = "成本构成汇总,groupBy=month/product")
    public R<List<CostSummaryVo>> summary(
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
            @RequestParam(required = false) String npsNo,
            @RequestParam(required = false) String productName,
            @RequestParam(required = false, defaultValue = "month") String groupBy) {
        return R.ok(costAccountingService.summary(startDate, endDate, npsNo, productName, groupBy));
    }
    @GetMapping("/missingPrice")
    @Operation(summary = "缺采购单价的消耗物料清单")
    public R<List<CostMissingPriceVo>> missingPrice(
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
            @RequestParam(required = false) String npsNo,
            @RequestParam(required = false) String productName) {
        return R.ok(costAccountingService.missingPrice(startDate, endDate, npsNo, productName));
    }
    @GetMapping("/stat/export")
    @Log(title = "Export cost stat", businessType = BusinessType.EXPORT)
    @Operation(summary = "成本核算明细导出")
    public void exportStat(HttpServletResponse response,
                           @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
                           @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
                           @RequestParam(required = false) String npsNo,
                           @RequestParam(required = false) String productName) {
        List<CostStatVo> list = costAccountingService.stat(startDate, endDate, npsNo, productName);
        ExcelUtil<CostStatVo> util = new ExcelUtil<>(CostStatVo.class);
        util.exportExcel(response, list, "成本核算明细");
    }
    @GetMapping("/summary/export")
    @Log(title = "Export cost summary", businessType = BusinessType.EXPORT)
    @Operation(summary = "成本构成汇总导出")
    public void exportSummary(HttpServletResponse response,
                              @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
                              @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
                              @RequestParam(required = false) String npsNo,
                              @RequestParam(required = false) String productName,
                              @RequestParam(required = false, defaultValue = "month") String groupBy) {
        List<CostSummaryVo> list = costAccountingService.summary(startDate, endDate, npsNo, productName, groupBy);
        ExcelUtil<CostSummaryVo> util = new ExcelUtil<>(CostSummaryVo.class);
        util.exportExcel(response, list, "成本构成汇总");
    }
    @GetMapping("/missingPrice/export")
    @Log(title = "Export missing price materials", businessType = BusinessType.EXPORT)
    @Operation(summary = "缺价物料清单导出")
    public void exportMissingPrice(HttpServletResponse response,
                                   @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
                                   @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
                                   @RequestParam(required = false) String npsNo,
                                   @RequestParam(required = false) String productName) {
        List<CostMissingPriceVo> list = costAccountingService.missingPrice(startDate, endDate, npsNo, productName);
        ExcelUtil<CostMissingPriceVo> util = new ExcelUtil<>(CostMissingPriceVo.class);
        util.exportExcel(response, list, "缺价物料清单");
    }
}
src/main/java/com/ruoyi/cost/mapper/CostAccountingMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package com.ruoyi.cost.mapper;
import com.ruoyi.cost.bean.vo.CostEnergyVo;
import com.ruoyi.cost.bean.vo.CostLaborVo;
import com.ruoyi.cost.bean.vo.CostMaterialInputVo;
import com.ruoyi.cost.bean.vo.CostMaterialPriceVo;
import com.ruoyi.cost.bean.vo.CostMissingPriceVo;
import com.ruoyi.cost.bean.vo.CostOrderVo;
import com.ruoyi.cost.bean.vo.CostSalePriceVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
/**
 * æˆæœ¬æ ¸ç®—分析(只读,按生产订单归集材料/人工/能耗)
 */
@Mapper
public interface CostAccountingMapper {
    /** è®¢å•基础信息 */
    List<CostOrderVo> listOrders(@Param("startDate") LocalDate startDate,
                                @Param("endDate") LocalDate endDate,
                                @Param("npsNo") String npsNo,
                                @Param("productName") String productName);
    /** è®¢å•物料实际投入(报工投入口径) */
    List<CostMaterialInputVo> listMaterialInputs(@Param("startDate") LocalDate startDate,
                                                @Param("endDate") LocalDate endDate,
                                                @Param("npsNo") String npsNo,
                                                @Param("productName") String productName);
    /** æ¶ˆè€—物料汇总(用于缺价清单) */
    List<CostMissingPriceVo> listConsumedMaterials(@Param("startDate") LocalDate startDate,
                                                  @Param("endDate") LocalDate endDate,
                                                  @Param("npsNo") String npsNo,
                                                  @Param("productName") String productName);
    /** ç‰©æ–™å•价(多来源,priority è¶Šå°è¶Šä¼˜å…ˆï¼‰ */
    List<CostMaterialPriceVo> listMaterialPrices();
    /** è®¢å•人工成本(与报工工资口径一致:计件优先,其次计时,再次规格内嵌单价) */
    List<CostLaborVo> listLaborCosts(@Param("startDate") LocalDate startDate,
                                    @Param("endDate") LocalDate endDate,
                                    @Param("npsNo") String npsNo,
                                    @Param("productName") String productName);
    /** è®¢å•关联的销售含税单价 */
    List<CostSalePriceVo> listSalePrices(@Param("startDate") LocalDate startDate,
                                        @Param("endDate") LocalDate endDate,
                                        @Param("npsNo") String npsNo,
                                        @Param("productName") String productName);
    /** åŒºé—´ç”µè´¹æ€»é¢ï¼ˆè®¾å¤‡èƒ½è€—×当日电价) */
    CostEnergyVo sumEnergyCost(@Param("startDate") LocalDate startDate,
                               @Param("endDate") LocalDate endDate);
}
src/main/java/com/ruoyi/cost/service/CostAccountingService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package com.ruoyi.cost.service;
import com.ruoyi.cost.bean.vo.CostMissingPriceVo;
import com.ruoyi.cost.bean.vo.CostStatVo;
import com.ruoyi.cost.bean.vo.CostSummaryVo;
import java.time.LocalDate;
import java.util.List;
/**
 * æˆæœ¬æ ¸ç®—分析:按生产订单归集材料/人工/能耗成本,输出单位成本与毛利
 */
public interface CostAccountingService {
    /**
     * æˆæœ¬æ ¸ç®—明细(一行一个生产订单)
     */
    List<CostStatVo> stat(LocalDate startDate, LocalDate endDate, String npsNo, String productName);
    /**
     * æˆæœ¬æž„成汇总
     *
     * @param groupBy month=按订单月份,product=按成品
     */
    List<CostSummaryVo> summary(LocalDate startDate, LocalDate endDate,
                                String npsNo, String productName, String groupBy);
    /**
     * ç¼ºé‡‡è´­å•价的消耗物料清单(供补录价格)
     */
    List<CostMissingPriceVo> missingPrice(LocalDate startDate, LocalDate endDate,
                                          String npsNo, String productName);
}
src/main/java/com/ruoyi/cost/service/impl/CostAccountingServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,237 @@
package com.ruoyi.cost.service.impl;
import com.ruoyi.cost.bean.vo.CostEnergyVo;
import com.ruoyi.cost.bean.vo.CostLaborVo;
import com.ruoyi.cost.bean.vo.CostMaterialInputVo;
import com.ruoyi.cost.bean.vo.CostMaterialPriceVo;
import com.ruoyi.cost.bean.vo.CostMissingPriceVo;
import com.ruoyi.cost.bean.vo.CostOrderVo;
import com.ruoyi.cost.bean.vo.CostSalePriceVo;
import com.ruoyi.cost.bean.vo.CostStatVo;
import com.ruoyi.cost.bean.vo.CostSummaryVo;
import com.ruoyi.cost.mapper.CostAccountingMapper;
import com.ruoyi.cost.service.CostAccountingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class CostAccountingServiceImpl implements CostAccountingService {
    private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
    private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
    private final CostAccountingMapper costAccountingMapper;
    @Override
    public List<CostStatVo> stat(LocalDate startDate, LocalDate endDate, String npsNo, String productName) {
        List<CostOrderVo> orders = costAccountingMapper.listOrders(startDate, endDate, npsNo, productName);
        if (orders.isEmpty()) {
            return new ArrayList<>();
        }
        Map<Long, List<CostMaterialInputVo>> inputsByOrder = costAccountingMapper
                .listMaterialInputs(startDate, endDate, npsNo, productName).stream()
                .filter(i -> i.getProductionOrderId() != null)
                .collect(Collectors.groupingBy(CostMaterialInputVo::getProductionOrderId));
        Map<Long, CostMaterialPriceVo> priceByModel = priceByModel();
        Map<Long, CostLaborVo> laborByOrder = costAccountingMapper
                .listLaborCosts(startDate, endDate, npsNo, productName).stream()
                .filter(l -> l.getProductionOrderId() != null)
                .collect(Collectors.toMap(CostLaborVo::getProductionOrderId, l -> l, (a, b) -> a));
        Map<Long, BigDecimal> salePriceByOrder = new LinkedHashMap<>();
        for (CostSalePriceVo sale : costAccountingMapper.listSalePrices(startDate, endDate, npsNo, productName)) {
            if (sale.getProductionOrderId() != null) {
                //SQL å·²æŒ‰ slp.id å€’序,保留最新一张销售单的单价
                salePriceByOrder.putIfAbsent(sale.getProductionOrderId(), sale.getSalePrice());
            }
        }
        CostEnergyVo energy = nvlEnergy(costAccountingMapper.sumEnergyCost(startDate, endDate));
        BigDecimal energyTotal = nvl(energy.getEnergyCost());
        BigDecimal totalOutput = orders.stream().map(this::outputQty).reduce(BigDecimal.ZERO, BigDecimal::add);
        List<CostStatVo> result = new ArrayList<>();
        for (CostOrderVo order : orders) {
            BigDecimal output = outputQty(order);
            //材料成本:Σ(投入量×单价),同时统计已计价消耗量与缺价物料数
            BigDecimal materialCost = BigDecimal.ZERO;
            BigDecimal pricedQty = BigDecimal.ZERO;
            BigDecimal inputQty = BigDecimal.ZERO;
            int missingKinds = 0;
            for (CostMaterialInputVo input : inputsByOrder.getOrDefault(order.getProductionOrderId(), new ArrayList<>())) {
                BigDecimal qty = nvl(input.getInputQty());
                inputQty = inputQty.add(qty);
                CostMaterialPriceVo price = input.getProductModelId() == null
                        ? null : priceByModel.get(input.getProductModelId());
                if (price == null || nvl(price.getPrice()).compareTo(BigDecimal.ZERO) <= 0) {
                    if (qty.compareTo(BigDecimal.ZERO) != 0) {
                        missingKinds++;
                    }
                    continue;
                }
                pricedQty = pricedQty.add(qty);
                materialCost = materialCost.add(qty.multiply(nvl(price.getPrice())));
            }
            materialCost = scale(materialCost, 2);
            //人工成本
            CostLaborVo labor = laborByOrder.get(order.getProductionOrderId());
            BigDecimal laborCost = labor == null ? BigDecimal.ZERO : scale(nvl(labor.getLaborCost()), 2);
            //能耗成本:区间电费按产量占比分摊
            BigDecimal energyCost = energyTotal.compareTo(BigDecimal.ZERO) > 0 && totalOutput.compareTo(BigDecimal.ZERO) > 0
                    ? scale(energyTotal.multiply(output).divide(totalOutput, 6, RoundingMode.HALF_UP), 2)
                    : BigDecimal.ZERO;
            BigDecimal totalCost = materialCost.add(laborCost).add(energyCost);
            BigDecimal unitCost = output.compareTo(BigDecimal.ZERO) > 0
                    ? totalCost.divide(output, 4, RoundingMode.HALF_UP) : null;
            BigDecimal salePrice = salePriceByOrder.get(order.getProductionOrderId());
            CostStatVo vo = new CostStatVo();
            vo.setProductionOrderId(order.getProductionOrderId());
            vo.setNpsNo(order.getNpsNo());
            vo.setFinishedProduct(order.getFinishedProduct());
            vo.setUnit(order.getUnit());
            vo.setOrderDate(order.getCreateTime() == null ? null : order.getCreateTime().toLocalDate());
            vo.setOutputQty(output);
            vo.setMaterialCost(materialCost);
            vo.setLaborCost(laborCost);
            vo.setEnergyCost(energyCost);
            vo.setTotalCost(scale(totalCost, 2));
            vo.setUnitCost(unitCost);
            vo.setSalePrice(salePrice);
            if (salePrice != null && unitCost != null) {
                BigDecimal profit = salePrice.subtract(unitCost).multiply(output);
                vo.setGrossProfit(scale(profit, 2));
                BigDecimal revenue = salePrice.multiply(output);
                if (revenue.compareTo(BigDecimal.ZERO) != 0) {
                    vo.setGrossProfitRate(profit.multiply(HUNDRED).divide(revenue, 2, RoundingMode.HALF_UP));
                }
            }
            if (inputQty.compareTo(BigDecimal.ZERO) > 0) {
                vo.setPriceCoverageRate(pricedQty.multiply(HUNDRED).divide(inputQty, 2, RoundingMode.HALF_UP));
            }
            vo.setMissingPriceKinds(missingKinds);
            vo.setNoRateRows(labor == null ? 0 : labor.getNoRateRows());
            result.add(vo);
        }
        return result;
    }
    @Override
    public List<CostSummaryVo> summary(LocalDate startDate, LocalDate endDate,
                                       String npsNo, String productName, String groupBy) {
        List<CostStatVo> stats = stat(startDate, endDate, npsNo, productName);
        boolean byProduct = "product".equals(groupBy);
        Map<String, CostSummaryVo> grouped = new LinkedHashMap<>();
        for (CostStatVo stat : stats) {
            String key = byProduct ? nvlText(stat.getFinishedProduct())
                    : (stat.getOrderDate() == null ? "未填日期" : stat.getOrderDate().format(MONTH_FORMAT));
            CostSummaryVo vo = grouped.computeIfAbsent(key, k -> {
                CostSummaryVo created = new CostSummaryVo();
                created.setGroupKey(k);
                created.setOrderCount(0);
                created.setOutputQty(BigDecimal.ZERO);
                created.setMaterialCost(BigDecimal.ZERO);
                created.setLaborCost(BigDecimal.ZERO);
                created.setEnergyCost(BigDecimal.ZERO);
                created.setTotalCost(BigDecimal.ZERO);
                created.setGrossProfit(BigDecimal.ZERO);
                return created;
            });
            vo.setOrderCount(vo.getOrderCount() + 1);
            vo.setOutputQty(vo.getOutputQty().add(nvl(stat.getOutputQty())));
            vo.setMaterialCost(vo.getMaterialCost().add(nvl(stat.getMaterialCost())));
            vo.setLaborCost(vo.getLaborCost().add(nvl(stat.getLaborCost())));
            vo.setEnergyCost(vo.getEnergyCost().add(nvl(stat.getEnergyCost())));
            vo.setTotalCost(vo.getTotalCost().add(nvl(stat.getTotalCost())));
            vo.setGrossProfit(vo.getGrossProfit().add(nvl(stat.getGrossProfit())));
        }
        List<CostSummaryVo> result = new ArrayList<>(grouped.values());
        for (CostSummaryVo vo : result) {
            BigDecimal total = vo.getTotalCost();
            vo.setMaterialCost(scale(vo.getMaterialCost(), 2));
            vo.setLaborCost(scale(vo.getLaborCost(), 2));
            vo.setEnergyCost(scale(vo.getEnergyCost(), 2));
            vo.setTotalCost(scale(total, 2));
            vo.setGrossProfit(scale(vo.getGrossProfit(), 2));
            if (vo.getOutputQty().compareTo(BigDecimal.ZERO) > 0) {
                vo.setUnitCost(total.divide(vo.getOutputQty(), 4, RoundingMode.HALF_UP));
            }
            if (total.compareTo(BigDecimal.ZERO) > 0) {
                vo.setMaterialRate(rate(vo.getMaterialCost(), total));
                vo.setLaborRate(rate(vo.getLaborCost(), total));
                vo.setEnergyRate(rate(vo.getEnergyCost(), total));
            }
        }
        return result;
    }
    @Override
    public List<CostMissingPriceVo> missingPrice(LocalDate startDate, LocalDate endDate,
                                                 String npsNo, String productName) {
        Map<Long, CostMaterialPriceVo> priceByModel = priceByModel();
        return costAccountingMapper.listConsumedMaterials(startDate, endDate, npsNo, productName).stream()
                .filter(m -> m.getProductModelId() != null && !priceByModel.containsKey(m.getProductModelId()))
                .collect(Collectors.toList());
    }
    /** å•价来源按优先级取第一条(SQL å·²æŒ‰ productModelId、priority æŽ’序) */
    private Map<Long, CostMaterialPriceVo> priceByModel() {
        Map<Long, CostMaterialPriceVo> map = new HashMap<>();
        for (CostMaterialPriceVo price : costAccountingMapper.listMaterialPrices()) {
            if (price.getProductModelId() != null) {
                map.putIfAbsent(price.getProductModelId(), price);
            }
        }
        return map;
    }
    /** äº§é‡å£å¾„:完工数量优先,未完工回退计划数量 */
    private BigDecimal outputQty(CostOrderVo order) {
        return nvl(order.getCompleteQty()).compareTo(BigDecimal.ZERO) > 0
                ? order.getCompleteQty() : nvl(order.getPlanQty());
    }
    private BigDecimal rate(BigDecimal part, BigDecimal total) {
        return part.multiply(HUNDRED).divide(total, 2, RoundingMode.HALF_UP);
    }
    private CostEnergyVo nvlEnergy(CostEnergyVo energy) {
        if (energy == null) {
            energy = new CostEnergyVo();
        }
        if (energy.getKwh() == null) {
            energy.setKwh(BigDecimal.ZERO);
        }
        if (energy.getEnergyCost() == null) {
            energy.setEnergyCost(BigDecimal.ZERO);
        }
        return energy;
    }
    private BigDecimal nvl(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private String nvlText(String value) {
        return value == null ? "" : value;
    }
    private BigDecimal scale(BigDecimal value, int scale) {
        return nvl(value).setScale(scale, RoundingMode.HALF_UP);
    }
}
src/main/java/com/ruoyi/procurementrecord/controller/ProcurementDemandController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package com.ruoyi.procurementrecord.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.procurementrecord.pojo.ProcurementDemand;
import com.ruoyi.procurementrecord.service.ProcurementDemandService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@Tag(name = "采购需求")
@RequestMapping("/procurementDemand")
@AllArgsConstructor
public class ProcurementDemandController extends BaseController {
    private final ProcurementDemandService procurementDemandService;
    @GetMapping("/listPage")
    @Operation(summary = "采购需求-分页查询")
    public AjaxResult listPage(Page<ProcurementDemand> page, ProcurementDemand req) {
        return AjaxResult.success(procurementDemandService.pageList(page, req));
    }
    @PutMapping("/changeStatus")
    @Log(title = "采购需求状态变更", businessType = BusinessType.UPDATE)
    @Operation(summary = "采购需求-状态变更(0待处理/1已转采购/2已取消)")
    public AjaxResult changeStatus(@RequestParam List<Long> ids, @RequestParam Integer status) {
        return toAjax(procurementDemandService.changeStatus(ids, status));
    }
    @DeleteMapping("/del")
    @Log(title = "删除采购需求", businessType = BusinessType.DELETE)
    @Operation(summary = "采购需求-删除(仅待处理)")
    public AjaxResult del(@RequestBody List<Long> ids) {
        return toAjax(procurementDemandService.removePending(ids));
    }
    @PostMapping("/export")
    @Log(title = "导出采购需求", businessType = BusinessType.EXPORT)
    @Operation(summary = "采购需求-导出")
    public void export(HttpServletResponse response, ProcurementDemand req) {
        List<ProcurementDemand> list = procurementDemandService.listByCondition(req);
        ExcelUtil<ProcurementDemand> util = new ExcelUtil<>(ProcurementDemand.class);
        util.exportExcel(response, list, "采购需求");
    }
}
src/main/java/com/ruoyi/procurementrecord/mapper/ProcurementDemandMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
package com.ruoyi.procurementrecord.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.procurementrecord.pojo.ProcurementDemand;
public interface ProcurementDemandMapper extends BaseMapper<ProcurementDemand> {
}
src/main/java/com/ruoyi/procurementrecord/pojo/ProcurementDemand.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
package com.ruoyi.procurementrecord.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("procurement_demand")
@Schema(name = "ProcurementDemand对象", description = "采购需求-BOM齐套缺口")
public class ProcurementDemand implements Serializable {
    private static final long serialVersionUID = 1L;
    @Schema(description = "主键")
    @TableId(type = IdType.AUTO)
    private Long id;
    @Schema(description = "产品规格id")
    private Long productModelId;
    @Excel(name = "产品名称")
    @Schema(description = "产品名称")
    private String productName;
    @Excel(name = "规格型号")
    @Schema(description = "规格型号")
    private String productModel;
    @Excel(name = "单位")
    @Schema(description = "单位")
    private String unit;
    @Excel(name = "缺口需求量")
    @Schema(description = "缺口需求量")
    private BigDecimal demandQuantity;
    @Excel(name = "可用库存快照")
    @Schema(description = "生成时可用库存快照")
    private BigDecimal stockQuantity;
    @Schema(description = "来源BOM id")
    private Long sourceBomId;
    @Excel(name = "来源BOM编号")
    @Schema(description = "来源BOM编号")
    private String sourceBomNo;
    @Excel(name = "状态", readConverterExp = "0=待处理,1=已转采购,2=已取消")
    @Schema(description = "0待处理 1已转采购 2已取消")
    private Integer status;
    @Excel(name = "备注")
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间")
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    @Schema(description = "更新时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
    @Schema(description = "创建人")
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @Schema(description = "更新人")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    @Schema(description = "部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/procurementrecord/service/ProcurementDemandService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package com.ruoyi.procurementrecord.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.procurementrecord.pojo.ProcurementDemand;
import java.util.List;
public interface ProcurementDemandService extends IService<ProcurementDemand> {
    IPage<ProcurementDemand> pageList(Page<ProcurementDemand> page, ProcurementDemand req);
    List<ProcurementDemand> listByCondition(ProcurementDemand req);
    boolean changeStatus(List<Long> ids, Integer status);
    boolean removePending(List<Long> ids);
}
src/main/java/com/ruoyi/procurementrecord/service/impl/ProcurementDemandServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package com.ruoyi.procurementrecord.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.procurementrecord.mapper.ProcurementDemandMapper;
import com.ruoyi.procurementrecord.pojo.ProcurementDemand;
import com.ruoyi.procurementrecord.service.ProcurementDemandService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProcurementDemandServiceImpl extends ServiceImpl<ProcurementDemandMapper, ProcurementDemand> implements ProcurementDemandService {
    @Override
    public IPage<ProcurementDemand> pageList(Page<ProcurementDemand> page, ProcurementDemand req) {
        return this.page(page, buildWrapper(req));
    }
    @Override
    public List<ProcurementDemand> listByCondition(ProcurementDemand req) {
        return this.list(buildWrapper(req));
    }
    private LambdaQueryWrapper<ProcurementDemand> buildWrapper(ProcurementDemand req) {
        ProcurementDemand condition = req == null ? new ProcurementDemand() : req;
        return Wrappers.<ProcurementDemand>lambdaQuery()
                .like(StringUtils.isNotEmpty(condition.getProductName()), ProcurementDemand::getProductName, condition.getProductName())
                .like(StringUtils.isNotEmpty(condition.getProductModel()), ProcurementDemand::getProductModel, condition.getProductModel())
                .eq(condition.getStatus() != null, ProcurementDemand::getStatus, condition.getStatus())
                .orderByDesc(ProcurementDemand::getId);
    }
    @Override
    public boolean changeStatus(List<Long> ids, Integer status) {
        if (ids == null || ids.isEmpty() || status == null || status < 0 || status > 2) {
            throw new ServiceException("请选择要操作的需求并传入合法状态(0待处理/1已转采购/2已取消)");
        }
        return this.update(Wrappers.<ProcurementDemand>lambdaUpdate()
                .set(ProcurementDemand::getStatus, status)
                .in(ProcurementDemand::getId, ids));
    }
    @Override
    public boolean removePending(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("请至少选择一条采购需求");
        }
        long handledCount = this.count(Wrappers.<ProcurementDemand>lambdaQuery()
                .in(ProcurementDemand::getId, ids)
                .ne(ProcurementDemand::getStatus, 0));
        if (handledCount > 0) {
            throw new ServiceException("存在非待处理状态的需求,只能删除待处理数据");
        }
        return this.removeByIds(ids);
    }
}
src/main/java/com/ruoyi/production/bean/dto/ProductionProductMainDto.java
@@ -69,6 +69,9 @@
    @Schema(description = "工资")
    private BigDecimal wages;
    @Schema(description = "工资计算方式 è®¡ä»¶/计时")
    private String wageMode;
    @Schema(description = "工序参数列表")
    private List<ProductionOrderRoutingOperationParam> productionOperationParamList;
}
src/main/java/com/ruoyi/production/bean/vo/DeviceOutputStatVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.ruoyi.production.bean.vo;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "DeviceOutputStatVo", description = "产量统计(设备/工序/产品维度)")
public class DeviceOutputStatVo {
    @Schema(description = "分组id(设备维度为device_id)")
    private Long groupId;
    @Excel(name = "统计对象")
    @Schema(description = "分组名称:设备名/工序名/产品-规格")
    private String groupName;
    @Excel(name = "报工单数")
    @Schema(description = "报工单数")
    private Long workCount;
    @Excel(name = "生产数量")
    @Schema(description = "生产数量")
    private BigDecimal quantity;
    @Excel(name = "报废数量")
    @Schema(description = "报废数量")
    private BigDecimal scrapQty;
    @Excel(name = "总工时")
    @Schema(description = "总工时")
    private BigDecimal workHour;
}
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionActualVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package com.ruoyi.production.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "MaterialConsumptionActualVo", description = "订单物料实际投入行")
public class MaterialConsumptionActualVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Schema(description = "实际投入合计")
    private BigDecimal actualQty;
}
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionOrderVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package com.ruoyi.production.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "MaterialConsumptionOrderVo", description = "材料消耗核算-订单基础行")
public class MaterialConsumptionOrderVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "生产订单号")
    private String npsNo;
    @Schema(description = "成品规格id")
    private Long productModelId;
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Schema(description = "完工数量")
    private BigDecimal completeQty;
    @Schema(description = "计划数量")
    private BigDecimal planQty;
}
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionStatVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package com.ruoyi.production.bean.vo;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "MaterialConsumptionStatVo", description = "材料消耗核算(标准vs实际)")
public class MaterialConsumptionStatVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Excel(name = "生产订单号")
    @Schema(description = "生产订单号")
    private String npsNo;
    @Excel(name = "成品")
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Schema(description = "物料规格id")
    private Long materialModelId;
    @Excel(name = "物料名称")
    @Schema(description = "物料名称")
    private String materialName;
    @Excel(name = "物料规格")
    @Schema(description = "物料规格")
    private String materialModel;
    @Excel(name = "单位")
    @Schema(description = "单位")
    private String unit;
    @Excel(name = "BOM单耗")
    @Schema(description = "BOM单位用量")
    private BigDecimal unitQuantity;
    @Excel(name = "完工数量")
    @Schema(description = "订单完工数量")
    private BigDecimal completeQty;
    @Excel(name = "标准消耗")
    @Schema(description = "标准消耗=BOM单耗×完工数量")
    private BigDecimal standardQty;
    @Excel(name = "实际消耗")
    @Schema(description = "实际消耗=报工投入合计")
    private BigDecimal actualQty;
    @Excel(name = "差异数量")
    @Schema(description = "差异=实际-标准")
    private BigDecimal diffQty;
    @Excel(name = "差异率%")
    @Schema(description = "差异率=差异/标准×100")
    private BigDecimal diffRate;
}
src/main/java/com/ruoyi/production/bean/vo/MaterialConsumptionStructureVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.production.bean.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "MaterialConsumptionStructureVo", description = "订单BOM结构行")
public class MaterialConsumptionStructureVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "结构行id")
    private Long id;
    @Schema(description = "父结构行id(根为空/0)")
    private Long parentId;
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Schema(description = "单位用量")
    private BigDecimal unitQuantity;
}
src/main/java/com/ruoyi/production/bean/vo/PieceWageSummaryVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.ruoyi.production.bean.vo;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "PieceWageSummaryVo", description = "计件工资汇总(按人员)")
public class PieceWageSummaryVo {
    @Schema(description = "人员id")
    private Long userId;
    @Excel(name = "姓名")
    @Schema(description = "姓名")
    private String userName;
    @Excel(name = "报工单数")
    @Schema(description = "报工单数")
    private Long workCount;
    @Excel(name = "生产数量")
    @Schema(description = "生产数量")
    private BigDecimal quantity;
    @Excel(name = "总工时")
    @Schema(description = "总工时")
    private BigDecimal workHour;
    @Excel(name = "计件工资")
    @Schema(description = "计件工资(命中计件单价的部分)")
    private BigDecimal pieceWage;
    @Excel(name = "应发工资合计")
    @Schema(description = "应发工资合计(计件优先,无单价回退原工时定额口径)")
    private BigDecimal wages;
}
src/main/java/com/ruoyi/production/controller/MaterialConsumptionController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package com.ruoyi.production.controller;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.production.bean.vo.MaterialConsumptionStatVo;
import com.ruoyi.production.service.MaterialConsumptionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/materialConsumption")
@RequiredArgsConstructor
@Tag(name = "材料消耗核算")
public class MaterialConsumptionController {
    private final MaterialConsumptionService materialConsumptionService;
    @GetMapping("/stat")
    @Operation(summary = "材料消耗核算(BOM标准vs实际投入)")
    public R<List<MaterialConsumptionStatVo>> stat(
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
            @RequestParam(required = false) String npsNo,
            @RequestParam(required = false) String productName,
            @RequestParam(required = false) Boolean onlyDiff) {
        return R.ok(materialConsumptionService.stat(startDate, endDate, npsNo, productName, onlyDiff));
    }
    @GetMapping("/stat/export")
    @Log(title = "Export material consumption", businessType = BusinessType.EXPORT)
    @Operation(summary = "材料消耗核算导出")
    public void export(HttpServletResponse response,
                       @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
                       @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
                       @RequestParam(required = false) String npsNo,
                       @RequestParam(required = false) String productName,
                       @RequestParam(required = false) Boolean onlyDiff) {
        List<MaterialConsumptionStatVo> list =
                materialConsumptionService.stat(startDate, endDate, npsNo, productName, onlyDiff);
        ExcelUtil<MaterialConsumptionStatVo> util = new ExcelUtil<>(MaterialConsumptionStatVo.class);
        util.exportExcel(response, list, "材料消耗核算");
    }
}
src/main/java/com/ruoyi/production/controller/PieceRateConfigController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package com.ruoyi.production.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.production.pojo.PieceRateConfig;
import com.ruoyi.production.service.PieceRateConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/pieceRateConfig")
@RequiredArgsConstructor
@Tag(name = "计件单价配置")
public class PieceRateConfigController {
    private final PieceRateConfigService pieceRateConfigService;
    @GetMapping("/listPage")
    @Operation(summary = "计件单价分页查询")
    public R<IPage<PieceRateConfig>> listPage(Page<PieceRateConfig> page, PieceRateConfig query) {
        return R.ok(pieceRateConfigService.listPage(page, query));
    }
    @PostMapping("/add")
    @Log(title = "Add piece rate config", businessType = BusinessType.INSERT)
    @Operation(summary = "新增计件单价")
    public R add(@RequestBody PieceRateConfig config) {
        return pieceRateConfigService.add(config);
    }
    @PutMapping("/update")
    @Log(title = "Update piece rate config", businessType = BusinessType.UPDATE)
    @Operation(summary = "修改计件单价")
    public R update(@RequestBody PieceRateConfig config) {
        return pieceRateConfigService.update(config);
    }
    @PutMapping("/changeStatus")
    @Log(title = "Change piece rate status", businessType = BusinessType.UPDATE)
    @Operation(summary = "启用/停用计件单价")
    public R changeStatus(@RequestBody PieceRateConfig config) {
        return pieceRateConfigService.changeStatus(config.getId(), config.getStatus());
    }
    @DeleteMapping("/batchDelete")
    @Log(title = "Delete piece rate config", businessType = BusinessType.DELETE)
    @Operation(summary = "批量删除计件单价")
    public R batchDelete(@RequestBody List<Long> ids) {
        return R.ok(pieceRateConfigService.removeByIds(ids));
    }
    @GetMapping("/export")
    @Log(title = "Export piece rate config", businessType = BusinessType.EXPORT)
    @Operation(summary = "导出计件单价配置")
    public void export(HttpServletResponse response, PieceRateConfig query) {
        List<PieceRateConfig> list = pieceRateConfigService.listPage(new Page<>(1, -1), query).getRecords();
        ExcelUtil<PieceRateConfig> util = new ExcelUtil<>(PieceRateConfig.class);
        util.exportExcel(response, list, "计件单价配置");
    }
}
src/main/java/com/ruoyi/production/controller/ProductionProductMainController.java
@@ -4,14 +4,18 @@
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.production.bean.dto.ProductionProductMainDto;
import com.ruoyi.production.bean.vo.DeviceOutputStatVo;
import com.ruoyi.production.bean.vo.PieceWageSummaryVo;
import com.ruoyi.production.service.ProductionProductMainService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@RequestMapping("/productionProductMain")
@@ -91,4 +95,46 @@
        ExcelUtil<ProductionProductMainDto> util = new ExcelUtil<ProductionProductMainDto>(ProductionProductMainDto.class);
        util.exportExcel(response, list, "生产报工数据");
    }
    @GetMapping("/wageSummary")
    @Operation(summary = "计件工资汇总(按人员,计件单价优先)")
    public R<List<PieceWageSummaryVo>> wageSummary(
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
            @RequestParam(required = false) String userName) {
        return R.ok(productionProductMainService.wageSummary(startDate, endDate, userName));
    }
    @GetMapping("/wageSummary/export")
    @Operation(summary = "计件工资汇总导出")
    public void wageSummaryExport(HttpServletResponse response,
                                  @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
                                  @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
                                  @RequestParam(required = false) String userName) {
        List<PieceWageSummaryVo> list = productionProductMainService.wageSummary(startDate, endDate, userName);
        ExcelUtil<PieceWageSummaryVo> util = new ExcelUtil<>(PieceWageSummaryVo.class);
        util.exportExcel(response, list, "计件工资汇总");
    }
    @GetMapping("/outputStat")
    @Operation(summary = "产量统计(dimension: device设备/operation工序/product产品)")
    public R<List<DeviceOutputStatVo>> outputStat(
            @RequestParam(required = false, defaultValue = "device") String dimension,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
            @RequestParam(required = false) Long deviceId) {
        return R.ok(productionProductMainService.outputStat(dimension, startDate, endDate, deviceId));
    }
    @GetMapping("/outputStat/export")
    @Operation(summary = "产量统计导出")
    public void outputStatExport(HttpServletResponse response,
                                 @RequestParam(required = false, defaultValue = "device") String dimension,
                                 @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
                                 @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
                                 @RequestParam(required = false) Long deviceId) {
        List<DeviceOutputStatVo> list = productionProductMainService.outputStat(dimension, startDate, endDate, deviceId);
        ExcelUtil<DeviceOutputStatVo> util = new ExcelUtil<>(DeviceOutputStatVo.class);
        util.exportExcel(response, list, "产量统计");
    }
}
src/main/java/com/ruoyi/production/mapper/MaterialConsumptionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package com.ruoyi.production.mapper;
import com.ruoyi.production.bean.vo.MaterialConsumptionOrderVo;
import com.ruoyi.production.bean.vo.MaterialConsumptionActualVo;
import com.ruoyi.production.bean.vo.MaterialConsumptionStructureVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface MaterialConsumptionMapper {
    List<MaterialConsumptionOrderVo> listOrders(@Param("startDate") LocalDate startDate,
                                                @Param("endDate") LocalDate endDate,
                                                @Param("npsNo") String npsNo,
                                                @Param("productName") String productName);
    List<MaterialConsumptionStructureVo> listStructures(@Param("orderIds") List<Long> orderIds);
    List<MaterialConsumptionActualVo> listActualInputs(@Param("orderIds") List<Long> orderIds);
}
src/main/java/com/ruoyi/production/mapper/PieceRateConfigMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package com.ruoyi.production.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.production.pojo.PieceRateConfig;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PieceRateConfigMapper extends BaseMapper<PieceRateConfig> {
}
src/main/java/com/ruoyi/production/mapper/ProductionProductMainMapper.java
@@ -5,11 +5,14 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.production.bean.dto.ProductionAccountDto;
import com.ruoyi.production.bean.dto.ProductionProductMainDto;
import com.ruoyi.production.bean.vo.DeviceOutputStatVo;
import com.ruoyi.production.bean.vo.PieceWageSummaryVo;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionProductMain;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@@ -33,4 +36,13 @@
    IPage<ProductionProductMainDto> listProductionDetails(@Param("c") ProductionAccountDto productionAccountDto, Page page);
    ArrayList<Long> listMain(List<Long> idList);
    List<PieceWageSummaryVo> wageSummary(@Param("startDate") LocalDate startDate,
                                         @Param("endDate") LocalDate endDate,
                                         @Param("userName") String userName);
    List<DeviceOutputStatVo> outputStat(@Param("dimension") String dimension,
                                        @Param("startDate") LocalDate startDate,
                                        @Param("endDate") LocalDate endDate,
                                        @Param("deviceId") Long deviceId);
}
src/main/java/com/ruoyi/production/pojo/PieceRateConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
package com.ruoyi.production.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * è®¡ä»¶å•价配置(按产品规格+工序)
 */
@Data
@TableName("piece_rate_config")
@Schema(name = "PieceRateConfig", description = "计件单价配置")
public class PieceRateConfig implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(type = IdType.AUTO)
    private Long id;
    @Schema(description = "产品规格id")
    private Long productModelId;
    @Schema(description = "产品规格型号(冗余)")
    @Excel(name = "规格型号")
    private String productModel;
    @Schema(description = "工序名称")
    @Excel(name = "工序")
    private String operationName;
    @Schema(description = "计件单价(元/ä»¶)")
    @Excel(name = "计件单价(元/ä»¶)")
    private BigDecimal unitPrice;
    @Schema(description = "状态 1启用 0停用")
    @Excel(name = "状态", readConverterExp = "1=启用,0=停用")
    private Integer status;
    @Schema(description = "备注")
    @Excel(name = "备注")
    private String remark;
    @Schema(description = "创建时间")
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    @Schema(description = "更新时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
    @Schema(description = "创建人")
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @Schema(description = "更新人")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    @Schema(description = "部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/production/pojo/ProductionProductMain.java
@@ -27,6 +27,13 @@
    @Schema(description = "报工人员")
    private String userName;
    @Schema(description = "使用设备id(device_ledger.id)")
    private Long deviceId;
    @Schema(description = "设备名称(冗余)")
    @Excel(name = "使用设备")
    private String deviceName;
    @Schema(description = "生产工序工单id")
    private Long productionOperationTaskId;
src/main/java/com/ruoyi/production/service/MaterialConsumptionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package com.ruoyi.production.service;
import com.ruoyi.production.bean.vo.MaterialConsumptionStatVo;
import java.time.LocalDate;
import java.util.List;
public interface MaterialConsumptionService {
    /**
     * ææ–™æ¶ˆè€—核算:BOM标准消耗(单耗×完工量,未完工按计划量) vs æŠ¥å·¥å®žé™…投入
     */
    List<MaterialConsumptionStatVo> stat(LocalDate startDate, LocalDate endDate,
                                         String npsNo, String productName, Boolean onlyDiff);
}
src/main/java/com/ruoyi/production/service/PieceRateConfigService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package com.ruoyi.production.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.production.pojo.PieceRateConfig;
import java.math.BigDecimal;
public interface PieceRateConfigService extends IService<PieceRateConfig> {
    IPage<PieceRateConfig> listPage(Page<PieceRateConfig> page, PieceRateConfig query);
    R add(PieceRateConfig config);
    R update(PieceRateConfig config);
    R changeStatus(Long id, Integer status);
    /**
     * åŒ¹é…å¯ç”¨ä¸­çš„计件单价:规格+工序;无配置返回 null
     */
    BigDecimal matchPrice(Long productModelId, String operationName);
}
src/main/java/com/ruoyi/production/service/ProductionProductMainService.java
@@ -4,8 +4,11 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.production.bean.dto.ProductionProductMainDto;
import com.ruoyi.production.bean.vo.DeviceOutputStatVo;
import com.ruoyi.production.bean.vo.PieceWageSummaryVo;
import com.ruoyi.production.pojo.ProductionProductMain;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@@ -23,4 +26,8 @@
    Boolean removeProductMain(Long id);
    ArrayList<Long> listMain(List<Long> idList);
    List<PieceWageSummaryVo> wageSummary(LocalDate startDate, LocalDate endDate, String userName);
    List<DeviceOutputStatVo> outputStat(String dimension, LocalDate startDate, LocalDate endDate, Long deviceId);
}
src/main/java/com/ruoyi/production/service/impl/MaterialConsumptionServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,168 @@
package com.ruoyi.production.service.impl;
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.Product;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.production.bean.vo.MaterialConsumptionActualVo;
import com.ruoyi.production.bean.vo.MaterialConsumptionOrderVo;
import com.ruoyi.production.bean.vo.MaterialConsumptionStatVo;
import com.ruoyi.production.bean.vo.MaterialConsumptionStructureVo;
import com.ruoyi.production.mapper.MaterialConsumptionMapper;
import com.ruoyi.production.service.MaterialConsumptionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class MaterialConsumptionServiceImpl implements MaterialConsumptionService {
    private final MaterialConsumptionMapper materialConsumptionMapper;
    private final ProductModelMapper productModelMapper;
    private final ProductMapper productMapper;
    @Override
    public List<MaterialConsumptionStatVo> stat(LocalDate startDate, LocalDate endDate,
                                                String npsNo, String productName, Boolean onlyDiff) {
        List<MaterialConsumptionOrderVo> orders =
                materialConsumptionMapper.listOrders(startDate, endDate, npsNo, productName);
        if (orders.isEmpty()) {
            return new ArrayList<>();
        }
        List<Long> orderIds = orders.stream().map(MaterialConsumptionOrderVo::getProductionOrderId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        List<MaterialConsumptionStructureVo> structures = materialConsumptionMapper.listStructures(orderIds);
        List<MaterialConsumptionActualVo> actuals = materialConsumptionMapper.listActualInputs(orderIds);
        Map<Long, MaterialConsumptionStructureVo> structureById = structures.stream()
                .collect(Collectors.toMap(MaterialConsumptionStructureVo::getId, s -> s, (a, b) -> a));
        Map<String, BigDecimal> actualMap = new HashMap<>();
        for (MaterialConsumptionActualVo actual : actuals) {
            actualMap.merge(actual.getProductionOrderId() + "_" + actual.getProductModelId(),
                    nvl(actual.getActualQty()), BigDecimal::add);
        }
        //BOM逐层展开:累计单耗=上级累计×本级单耗,按物料合并
        Map<Long, Map<Long, BigDecimal>> orderStandard = new HashMap<>();
        for (MaterialConsumptionStructureVo row : structures) {
            if (isRoot(row)) {
                continue;
            }
            BigDecimal cumulative = cumulativeUnitQuantity(row, structureById);
            orderStandard.computeIfAbsent(row.getProductionOrderId(), k -> new LinkedHashMap<>())
                    .merge(row.getProductModelId(), cumulative, BigDecimal::add);
        }
        Set<Long> materialModelIds = new HashSet<>();
        orderStandard.values().forEach(m -> materialModelIds.addAll(m.keySet()));
        materialModelIds.addAll(actualMap.keySet().stream()
                .map(k -> Long.valueOf(k.substring(k.indexOf('_') + 1))).collect(Collectors.toSet()));
        Map<Long, ProductModel> modelMap = modelIds(materialModelIds);
        Map<Long, Product> productMap = products(modelMap.values());
        List<MaterialConsumptionStatVo> result = new ArrayList<>();
        for (MaterialConsumptionOrderVo order : orders) {
            Map<Long, BigDecimal> standardMap = orderStandard.getOrDefault(order.getProductionOrderId(), new LinkedHashMap<>());
            //报工投入了但BOM没有的物料也要展示(超标/误领)
            Set<Long> materialIds = new java.util.LinkedHashSet<>(standardMap.keySet());
            actualMap.keySet().stream()
                    .filter(k -> k.startsWith(order.getProductionOrderId() + "_"))
                    .forEach(k -> materialIds.add(Long.valueOf(k.substring(k.indexOf('_') + 1))));
            if (materialIds.isEmpty()) {
                continue;
            }
            BigDecimal outputQty = nvl(order.getCompleteQty()).compareTo(BigDecimal.ZERO) > 0
                    ? order.getCompleteQty() : nvl(order.getPlanQty());
            for (Long materialId : materialIds) {
                BigDecimal unitQty = standardMap.getOrDefault(materialId, BigDecimal.ZERO);
                BigDecimal standard = unitQty.multiply(outputQty).setScale(4, RoundingMode.HALF_UP);
                BigDecimal actual = actualMap.getOrDefault(
                        order.getProductionOrderId() + "_" + materialId, BigDecimal.ZERO);
                BigDecimal diff = actual.subtract(standard);
                if (Boolean.TRUE.equals(onlyDiff) && diff.compareTo(BigDecimal.ZERO) == 0) {
                    continue;
                }
                MaterialConsumptionStatVo vo = new MaterialConsumptionStatVo();
                vo.setProductionOrderId(order.getProductionOrderId());
                vo.setNpsNo(order.getNpsNo());
                vo.setFinishedProduct(order.getFinishedProduct());
                vo.setMaterialModelId(materialId);
                ProductModel pm = modelMap.get(materialId);
                if (pm != null) {
                    vo.setMaterialModel(pm.getModel());
                    vo.setUnit(pm.getUnit());
                    Product p = productMap.get(pm.getProductId());
                    if (p != null) {
                        vo.setMaterialName(p.getProductName());
                    }
                }
                vo.setUnitQuantity(unitQty);
                vo.setCompleteQty(outputQty);
                vo.setStandardQty(standard);
                vo.setActualQty(actual);
                vo.setDiffQty(diff);
                if (standard.compareTo(BigDecimal.ZERO) > 0) {
                    vo.setDiffRate(diff.multiply(BigDecimal.valueOf(100))
                            .divide(standard, 2, RoundingMode.HALF_UP));
                }
                result.add(vo);
            }
        }
        return result;
    }
    private boolean isRoot(MaterialConsumptionStructureVo row) {
        return row.getParentId() == null || row.getParentId() == 0L;
    }
    private BigDecimal cumulativeUnitQuantity(MaterialConsumptionStructureVo row,
                                              Map<Long, MaterialConsumptionStructureVo> structureById) {
        BigDecimal cumulative = nvl(row.getUnitQuantity());
        Long parentId = row.getParentId();
        int guard = 0;
        while (parentId != null && parentId != 0L && guard++ < 20) {
            MaterialConsumptionStructureVo parent = structureById.get(parentId);
            if (parent == null) {
                break;
            }
            cumulative = cumulative.multiply(nvl(parent.getUnitQuantity()));
            parentId = parent.getParentId();
        }
        return cumulative.setScale(6, RoundingMode.HALF_UP);
    }
    private Map<Long, ProductModel> modelIds(Set<Long> ids) {
        if (ids.isEmpty()) {
            return new HashMap<>();
        }
        return productModelMapper.selectBatchIds(ids).stream()
                .collect(Collectors.toMap(ProductModel::getId, m -> m, (a, b) -> a));
    }
    private Map<Long, Product> products(java.util.Collection<ProductModel> models) {
        List<Long> ids = models.stream().map(ProductModel::getProductId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (ids.isEmpty()) {
            return new HashMap<>();
        }
        return productMapper.selectBatchIds(ids).stream()
                .collect(Collectors.toMap(Product::getId, p -> p, (a, b) -> a));
    }
    private BigDecimal nvl(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
}
src/main/java/com/ruoyi/production/service/impl/PieceRateConfigServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,127 @@
package com.ruoyi.production.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.production.mapper.PieceRateConfigMapper;
import com.ruoyi.production.pojo.PieceRateConfig;
import com.ruoyi.production.service.PieceRateConfigService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@AllArgsConstructor
@Service
@Transactional(rollbackFor = Exception.class)
public class PieceRateConfigServiceImpl extends ServiceImpl<PieceRateConfigMapper, PieceRateConfig> implements PieceRateConfigService {
    private final ProductModelMapper productModelMapper;
    @Override
    public IPage<PieceRateConfig> listPage(Page<PieceRateConfig> page, PieceRateConfig query) {
        return this.page(page, Wrappers.<PieceRateConfig>lambdaQuery()
                .like(query.getOperationName() != null && !query.getOperationName().isEmpty(),
                        PieceRateConfig::getOperationName, query.getOperationName())
                .like(query.getProductModel() != null && !query.getProductModel().isEmpty(),
                        PieceRateConfig::getProductModel, query.getProductModel())
                .eq(query.getProductModelId() != null, PieceRateConfig::getProductModelId, query.getProductModelId())
                .eq(query.getStatus() != null, PieceRateConfig::getStatus, query.getStatus())
                .orderByDesc(PieceRateConfig::getId));
    }
    @Override
    public R add(PieceRateConfig config) {
        validate(config);
        checkDuplicate(config.getProductModelId(), config.getOperationName(), null);
        fillProductModel(config);
        if (config.getStatus() == null) {
            config.setStatus(1);
        }
        this.save(config);
        return R.ok();
    }
    @Override
    public R update(PieceRateConfig config) {
        if (config.getId() == null) {
            throw new ServiceException("参数错误:id不能为空");
        }
        PieceRateConfig exist = this.getById(config.getId());
        if (exist == null) {
            throw new ServiceException("计件单价配置不存在");
        }
        validate(config);
        checkDuplicate(config.getProductModelId(), config.getOperationName(), config.getId());
        fillProductModel(config);
        this.updateById(config);
        return R.ok();
    }
    @Override
    public R changeStatus(Long id, Integer status) {
        if (id == null || status == null || (status != 0 && status != 1)) {
            throw new ServiceException("参数错误:id与状态(0/1)必传");
        }
        PieceRateConfig exist = this.getById(id);
        if (exist == null) {
            throw new ServiceException("计件单价配置不存在");
        }
        PieceRateConfig update = new PieceRateConfig();
        update.setId(id);
        update.setStatus(status);
        this.updateById(update);
        return R.ok();
    }
    @Override
    public BigDecimal matchPrice(Long productModelId, String operationName) {
        if (productModelId == null || operationName == null || operationName.isEmpty()) {
            return null;
        }
        PieceRateConfig config = this.getOne(Wrappers.<PieceRateConfig>lambdaQuery()
                .eq(PieceRateConfig::getProductModelId, productModelId)
                .eq(PieceRateConfig::getOperationName, operationName)
                .eq(PieceRateConfig::getStatus, 1)
                .orderByDesc(PieceRateConfig::getId)
                .last("limit 1"), false);
        return config == null ? null : config.getUnitPrice();
    }
    private void validate(PieceRateConfig config) {
        if (config.getProductModelId() == null) {
            throw new ServiceException("请选择产品规格");
        }
        if (config.getOperationName() == null || config.getOperationName().trim().isEmpty()) {
            throw new ServiceException("工序名称不能为空");
        }
        config.setOperationName(config.getOperationName().trim());
        if (config.getUnitPrice() == null || config.getUnitPrice().compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("计件单价必须大于0");
        }
    }
    private void checkDuplicate(Long productModelId, String operationName, Long excludeId) {
        long count = this.count(Wrappers.<PieceRateConfig>lambdaQuery()
                .eq(PieceRateConfig::getProductModelId, productModelId)
                .eq(PieceRateConfig::getOperationName, operationName)
                .ne(excludeId != null, PieceRateConfig::getId, excludeId));
        if (count > 0) {
            throw new ServiceException("该产品规格下工序[" + operationName + "]已存在计件单价配置");
        }
    }
    private void fillProductModel(PieceRateConfig config) {
        ProductModel model = productModelMapper.selectById(config.getProductModelId());
        if (model == null) {
            throw new ServiceException("产品规格不存在");
        }
        config.setProductModel(model.getModel());
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java
@@ -15,12 +15,17 @@
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.device.mapper.DeviceLedgerMapper;
import com.ruoyi.device.pojo.DeviceLedger;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.production.bean.dto.ProductStructureDto;
import com.ruoyi.production.bean.dto.ProductionProductMainDto;
import com.ruoyi.production.bean.vo.DeviceOutputStatVo;
import com.ruoyi.production.bean.vo.PieceWageSummaryVo;
import com.ruoyi.production.enums.ProductOrderStatusEnum;
import com.ruoyi.production.mapper.*;
import com.ruoyi.production.pojo.*;
import com.ruoyi.production.service.PieceRateConfigService;
import com.ruoyi.production.service.ProductionProductMainService;
import com.ruoyi.project.system.domain.SysUser;
import com.ruoyi.project.system.mapper.SysUserMapper;
@@ -43,6 +48,7 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -81,6 +87,8 @@
    private final StockUtils stockUtils;
    private final StockInRecordService stockInRecordService;
    private final StockInventoryService stockInventoryService;
    private final DeviceLedgerMapper deviceLedgerMapper;
    private final PieceRateConfigService pieceRateConfigService;
    @Override
    public IPage<ProductionProductMainDto> listPageProductionProductMainDto(Page page, ProductionProductMainDto productionProductMainDto) {
@@ -275,6 +283,14 @@
        productionProductMain.setProductNo(generateProductNo());
        productionProductMain.setUserId(user == null ? dto.getUserId() : user.getUserId());
        productionProductMain.setUserName(user == null ? dto.getUserName() : user.getNickName());
        if (dto.getDeviceId() != null) {
            DeviceLedger device = deviceLedgerMapper.selectById(dto.getDeviceId());
            if (device == null) {
                throw new ServiceException("设备不存在,ID=" + dto.getDeviceId());
            }
            productionProductMain.setDeviceId(device.getId());
            productionProductMain.setDeviceName(device.getDeviceName());
        }
        productionProductMain.setProductionOperationTaskId(taskId);
        productionProductMain.setStatus(0);
        productionProductMain.setWorkHour(dto.getWorkHour());
@@ -687,4 +703,18 @@
        // æŸ¥è¯¢ä¸»è¡¨ID集合
        return productionProductMainMapper.listMain(idList);
    }
    @Override
    public List<PieceWageSummaryVo> wageSummary(LocalDate startDate, LocalDate endDate, String userName) {
        return productionProductMainMapper.wageSummary(startDate, endDate, userName);
    }
    @Override
    public List<DeviceOutputStatVo> outputStat(String dimension, LocalDate startDate, LocalDate endDate, Long deviceId) {
        String dim = dimension == null || dimension.isEmpty() ? "device" : dimension;
        if (!Arrays.asList("device", "operation", "product").contains(dim)) {
            throw new ServiceException("统计维度不合法,仅支持 device/operation/product");
        }
        return productionProductMainMapper.outputStat(dim, startDate, endDate, deviceId);
    }
}
src/main/java/com/ruoyi/quality/controller/QualityTraceController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
package com.ruoyi.quality.controller;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.quality.dto.QualityTraceBatchOptionVo;
import com.ruoyi.quality.dto.QualityTraceForwardExportVo;
import com.ruoyi.quality.dto.QualityTraceForwardVo;
import com.ruoyi.quality.dto.QualityTraceReverseExportVo;
import com.ruoyi.quality.dto.QualityTraceReverseVo;
import com.ruoyi.quality.service.QualityTraceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
 * ç”Ÿäº§è´¨é‡è¿½æº¯ï¼ˆæ‰¹æ¬¡é“¾è·¯ï¼‰
 */
@RestController
@RequestMapping("/quality/trace")
@RequiredArgsConstructor
@Tag(name = "生产质量追溯")
public class QualityTraceController {
    private final QualityTraceService qualityTraceService;
    @GetMapping("/forward")
    @Operation(summary = "正向追溯:成品批号→订单/工序/投入/领料批次/检验/出库流向")
    @Log(title = "质量追溯正向查询", businessType = BusinessType.OTHER)
    public R<QualityTraceForwardVo> forward(@RequestParam String batchNo) {
        return R.ok(qualityTraceService.forward(batchNo));
    }
    @GetMapping("/batchOptions")
    @Operation(summary = "批号候选:type=finished成品批号/material原料批号")
    public R<List<QualityTraceBatchOptionVo>> batchOptions(@RequestParam(required = false, defaultValue = "finished") String type,
                                                           @RequestParam(required = false) String keyword,
                                                           @RequestParam(required = false, defaultValue = "50") Integer limit) {
        return R.ok(qualityTraceService.batchOptions(type, keyword, limit));
    }
    @GetMapping("/reverse")
    @Operation(summary = "反向追溯:原料批号→采购来源/使用订单/产出成品批号")
    @Log(title = "质量追溯反向查询", businessType = BusinessType.OTHER)
    public R<QualityTraceReverseVo> reverse(@RequestParam String batchNo) {
        return R.ok(qualityTraceService.reverse(batchNo));
    }
    @GetMapping("/forward/export")
    @Operation(summary = "正向追溯导出")
    @Log(title = "质量追溯正向导出", businessType = BusinessType.EXPORT)
    public void forwardExport(HttpServletResponse response, @RequestParam String batchNo) {
        List<QualityTraceForwardExportVo> list = qualityTraceService.forwardExportRows(batchNo);
        ExcelUtil<QualityTraceForwardExportVo> util = new ExcelUtil<>(QualityTraceForwardExportVo.class);
        util.exportExcel(response, list, "成品批次追溯");
    }
    @GetMapping("/reverse/export")
    @Operation(summary = "反向追溯导出")
    @Log(title = "质量追溯反向导出", businessType = BusinessType.EXPORT)
    public void reverseExport(HttpServletResponse response, @RequestParam String batchNo) {
        List<QualityTraceReverseExportVo> list = qualityTraceService.reverseExportRows(batchNo);
        ExcelUtil<QualityTraceReverseExportVo> util = new ExcelUtil<>(QualityTraceReverseExportVo.class);
        util.exportExcel(response, list, "原料批次流向追溯");
    }
}
src/main/java/com/ruoyi/quality/dto/QualityTraceBatchOptionVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "QualityTraceBatchOptionVo", description = "追溯批号候选项")
public class QualityTraceBatchOptionVo {
    @Schema(description = "批号")
    private String batchNo;
    @Schema(description = "产品名称")
    private String productName;
    @Schema(description = "产品规格")
    private String productModel;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "数量")
    private BigDecimal qty;
    @Schema(description = "入库时间")
    private LocalDateTime inTime;
    @Schema(description = "关联生产订单号(可追溯时)")
    private String npsNo;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceChainVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(name = "QualityTraceChainVo", description = "追溯链路(按生产订单分组)")
public class QualityTraceChainVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "生产订单号")
    private String npsNo;
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Schema(description = "成品单位")
    private String unit;
    @Schema(description = "订单计划数量")
    private BigDecimal planQty;
    @Schema(description = "订单完工数量")
    private BigDecimal completeQty;
    @Schema(description = "订单状态")
    private Integer orderStatus;
    @Schema(description = "订单创建时间")
    private LocalDateTime orderCreateTime;
    @Schema(description = "该订单下命中查询批号的成品入库记录")
    private List<QualityTraceFinishedBatchVo> batches = new ArrayList<>();
    @Schema(description = "报工(工序)节点")
    private List<QualityTraceReportVo> reports = new ArrayList<>();
    @Schema(description = "报工投入物料")
    private List<QualityTraceInputVo> inputs = new ArrayList<>();
    @Schema(description = "生产领料批次(原料批号)")
    private List<QualityTracePickVo> picks = new ArrayList<>();
    @Schema(description = "过程/出厂检验单")
    private List<QualityTraceInspectVo> inspects = new ArrayList<>();
    @Schema(description = "不合格处理记录")
    private List<QualityTraceUnqualifiedVo> unqualifiedList = new ArrayList<>();
}
src/main/java/com/ruoyi/quality/dto/QualityTraceFinishedBatchVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "QualityTraceFinishedBatchVo", description = "追溯-订单产出的成品批号")
public class QualityTraceFinishedBatchVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "关联报工单id")
    private Long productMainId;
    @Schema(description = "成品批号")
    private String batchNo;
    @Schema(description = "成品规格id")
    private Long productModelId;
    @Schema(description = "入库数量")
    private BigDecimal qty;
    @Schema(description = "入库时间")
    private LocalDateTime inTime;
    @Schema(description = "入库类型(2生产报工入库/6质检合格入库)")
    private String recordType;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceForwardExportVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package com.ruoyi.quality.dto;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "QualityTraceForwardExportVo", description = "正向追溯导出行(成品批号→原料批号)")
public class QualityTraceForwardExportVo {
    @Excel(name = "成品批号")
    @Schema(description = "成品批号")
    private String batchNo;
    @Excel(name = "生产订单号")
    @Schema(description = "生产订单号")
    private String npsNo;
    @Excel(name = "成品")
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Excel(name = "完工数量")
    @Schema(description = "订单完工数量")
    private BigDecimal completeQty;
    @Excel(name = "工序")
    @Schema(description = "报工工序汇总")
    private String operations;
    @Excel(name = "检验结论")
    @Schema(description = "检验结论汇总")
    private String inspectResults;
    @Excel(name = "不合格处理")
    @Schema(description = "不合格处理汇总")
    private String unqualifiedResults;
    @Excel(name = "原料批号")
    @Schema(description = "领料原料批号")
    private String materialBatchNo;
    @Excel(name = "物料")
    @Schema(description = "物料名称")
    private String materialName;
    @Excel(name = "物料规格")
    @Schema(description = "物料规格")
    private String materialModel;
    @Excel(name = "领料数量")
    @Schema(description = "领料数量")
    private BigDecimal pickQty;
    @Excel(name = "供应商")
    @Schema(description = "供应商")
    private String supplierName;
    @Excel(name = "原料检验结论")
    @Schema(description = "原料检验结论")
    private String materialCheckResult;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceForwardVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(name = "QualityTraceForwardVo", description = "正向追溯:成品批号→订单/工序/投入/检验/流出")
public class QualityTraceForwardVo {
    @Schema(description = "查询的成品批号")
    private String batchNo;
    @Schema(description = "批号对应的生产入库记录(可能多条/多个订单)")
    private List<QualityTraceChainVo> chains = new ArrayList<>();
    @Schema(description = "同批号出库流向")
    private List<QualityTraceOutboundVo> outbounds = new ArrayList<>();
}
src/main/java/com/ruoyi/quality/dto/QualityTraceInputVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(name = "QualityTraceInputVo", description = "追溯-报工投入物料")
public class QualityTraceInputVo {
    @Schema(description = "所属报工单id")
    private Long productMainId;
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Schema(description = "物料名称")
    private String materialName;
    @Schema(description = "物料规格")
    private String materialModel;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "投入数量")
    private BigDecimal inputQty;
    @Schema(description = "工单号")
    private String workOrderNo;
    @Schema(description = "工序名称")
    private String operationName;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceInspectVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(name = "QualityTraceInspectVo", description = "追溯-检验单")
public class QualityTraceInspectVo {
    @Schema(description = "检验单id")
    private Long inspectId;
    @Schema(description = "关联报工单id(报工检验时有值)")
    private Long productMainId;
    @Schema(description = "检验类型 0原料/1过程/2出厂")
    private Integer inspectType;
    @Schema(description = "检验类型名称")
    private String inspectTypeName;
    @Schema(description = "检验工序")
    private String process;
    @Schema(description = "检验员")
    private String checkName;
    @Schema(description = "产品名称")
    private String productName;
    @Schema(description = "规格型号")
    private String model;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "检验数量")
    private BigDecimal quantity;
    @Schema(description = "合格数量")
    private BigDecimal qualifiedQuantity;
    @Schema(description = "不合格数量")
    private BigDecimal unqualifiedQuantity;
    @Schema(description = "检验结论")
    private String checkResult;
    @Schema(description = "检验日期")
    private LocalDate checkTime;
    @Schema(description = "提交状态 0未提交/1已提交")
    private Integer inspectState;
    @Schema(description = "不合格现象")
    private String defectivePhenomena;
    @Schema(description = "该检验单产生的不合格处理记录")
    private List<QualityTraceUnqualifiedVo> unqualifiedList = new ArrayList<>();
}
src/main/java/com/ruoyi/quality/dto/QualityTraceMaterialBatchVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Schema(name = "QualityTraceMaterialBatchVo", description = "追溯-原料批号来源(入库/检验/采购)")
public class QualityTraceMaterialBatchVo {
    @Schema(description = "入库记录id")
    private Long inRecordId;
    @Schema(description = "批号")
    private String batchNo;
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Schema(description = "物料名称")
    private String materialName;
    @Schema(description = "物料规格")
    private String materialModel;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "入库数量")
    private BigDecimal qty;
    @Schema(description = "入库时间")
    private LocalDateTime inTime;
    @Schema(description = "入库类型(7采购入库/10采购质检合格入库/6质检合格入库/0自定义入库)")
    private String recordType;
    @Schema(description = "关联检验单id")
    private Long inspectId;
    @Schema(description = "检验数量")
    private BigDecimal inspectQty;
    @Schema(description = "合格数量")
    private BigDecimal qualifiedQty;
    @Schema(description = "不合格数量")
    private BigDecimal unqualifiedQty;
    @Schema(description = "检验结论")
    private String checkResult;
    @Schema(description = "检验日期")
    private LocalDate checkTime;
    @Schema(description = "供应商")
    private String supplierName;
    @Schema(description = "采购合同号")
    private String contractNo;
    @Schema(description = "采购日期")
    private LocalDate entryDate;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceOutboundVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "QualityTraceOutboundVo", description = "追溯-成品批号流出(出库记录)")
public class QualityTraceOutboundVo {
    @Schema(description = "出库记录id")
    private Long id;
    @Schema(description = "批号")
    private String batchNo;
    @Schema(description = "产品名称")
    private String productName;
    @Schema(description = "产品规格")
    private String productModel;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "出库数量")
    private BigDecimal qty;
    @Schema(description = "出库类型(8销售出库/13销售发货出库/3生产报工出库/1自定义出库)")
    private String recordType;
    @Schema(description = "出库时间")
    private LocalDateTime outTime;
    @Schema(description = "备注")
    private String remark;
}
src/main/java/com/ruoyi/quality/dto/QualityTracePickVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "QualityTracePickVo", description = "追溯-生产领料批次")
public class QualityTracePickVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "物料规格id")
    private Long productModelId;
    @Schema(description = "物料名称")
    private String materialName;
    @Schema(description = "物料规格")
    private String materialModel;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "原料批号")
    private String batchNo;
    @Schema(description = "领料数量")
    private BigDecimal quantity;
    @Schema(description = "领料工序")
    private String operationName;
    @Schema(description = "领料时间")
    private LocalDateTime pickTime;
    @Schema(description = "供应商(由原料批号回溯采购/检验记录带出)")
    private String supplierName;
    @Schema(description = "采购合同号")
    private String contractNo;
    @Schema(description = "原料检验结论")
    private String checkResult;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceReportVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "QualityTraceReportVo", description = "追溯-生产报工节点")
public class QualityTraceReportVo {
    @Schema(description = "报工单id(production_product_main.id)")
    private Long productMainId;
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "生产订单号")
    private String npsNo;
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Schema(description = "成品单位")
    private String unit;
    @Schema(description = "订单计划数量")
    private BigDecimal planQty;
    @Schema(description = "订单完工数量")
    private BigDecimal completeQty;
    @Schema(description = "订单状态")
    private Integer orderStatus;
    @Schema(description = "订单创建时间")
    private LocalDateTime orderCreateTime;
    @Schema(description = "工单号")
    private String workOrderNo;
    @Schema(description = "工序名称")
    private String operationName;
    @Schema(description = "报工人")
    private String userName;
    @Schema(description = "使用设备")
    private String deviceName;
    @Schema(description = "报工产出数量")
    private BigDecimal outputQty;
    @Schema(description = "报工报废数量")
    private BigDecimal scrapQty;
    @Schema(description = "报工单号")
    private String productNo;
    @Schema(description = "报工时间")
    private LocalDateTime reportTime;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceReverseExportVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
package com.ruoyi.quality.dto;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
@Schema(name = "QualityTraceReverseExportVo", description = "反向追溯导出行(原料批号→受影响成品批号)")
public class QualityTraceReverseExportVo {
    @Excel(name = "原料批号")
    @Schema(description = "原料批号")
    private String batchNo;
    @Excel(name = "物料")
    @Schema(description = "物料名称")
    private String materialName;
    @Excel(name = "物料规格")
    @Schema(description = "物料规格")
    private String materialModel;
    @Excel(name = "单位")
    @Schema(description = "单位")
    private String unit;
    @Excel(name = "供应商")
    @Schema(description = "供应商")
    private String supplierName;
    @Excel(name = "采购合同号")
    @Schema(description = "采购合同号")
    private String contractNo;
    @Excel(name = "采购日期", width = 20, dateFormat = "yyyy-MM-dd")
    @Schema(description = "采购日期")
    private LocalDate entryDate;
    @Excel(name = "原料检验结论")
    @Schema(description = "原料检验结论")
    private String materialCheckResult;
    @Excel(name = "生产订单号")
    @Schema(description = "生产订单号")
    private String npsNo;
    @Excel(name = "成品")
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Excel(name = "领用数量")
    @Schema(description = "该订单领用被查批号的数量")
    private BigDecimal pickedQty;
    @Excel(name = "产出成品批号")
    @Schema(description = "该订单产出的成品批号,多个以/分隔")
    private String finishedBatchNos;
}
src/main/java/com/ruoyi/quality/dto/QualityTraceReverseOrderVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(name = "QualityTraceReverseOrderVo", description = "反向追溯:原料批号→使用该批号的生产订单")
public class QualityTraceReverseOrderVo {
    @Schema(description = "生产订单id")
    private Long productionOrderId;
    @Schema(description = "生产订单号")
    private String npsNo;
    @Schema(description = "成品 åç§°/规格")
    private String finishedProduct;
    @Schema(description = "成品单位")
    private String unit;
    @Schema(description = "订单计划数量")
    private BigDecimal planQty;
    @Schema(description = "订单完工数量")
    private BigDecimal completeQty;
    @Schema(description = "订单状态")
    private Integer orderStatus;
    @Schema(description = "订单创建时间")
    private LocalDateTime orderCreateTime;
    @Schema(description = "该订单使用被查批号的领用合计")
    private BigDecimal pickedQty;
    @Schema(description = "该批号在本订单的领料明细")
    private List<QualityTracePickVo> picks = new ArrayList<>();
    @Schema(description = "本订单报工(工序)节点")
    private List<QualityTraceReportVo> reports = new ArrayList<>();
    @Schema(description = "本订单产出的成品批号")
    private List<QualityTraceFinishedBatchVo> finishedBatches = new ArrayList<>();
}
src/main/java/com/ruoyi/quality/dto/QualityTraceReverseVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(name = "QualityTraceReverseVo", description = "反向追溯:原料批号→采购来源+受影响订单+成品批号")
public class QualityTraceReverseVo {
    @Schema(description = "查询的原料批号")
    private String batchNo;
    @Schema(description = "批号入库/检验/采购来源(取首条入库记录)")
    private QualityTraceMaterialBatchVo material;
    @Schema(description = "使用该批号的生产订单")
    private List<QualityTraceReverseOrderVo> orders = new ArrayList<>();
}
src/main/java/com/ruoyi/quality/dto/QualityTraceUnqualifiedVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package com.ruoyi.quality.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
@Schema(name = "QualityTraceUnqualifiedVo", description = "追溯-不合格处理记录")
public class QualityTraceUnqualifiedVo {
    @Schema(description = "不合格记录id")
    private Long id;
    @Schema(description = "关联检验单id")
    private Long inspectId;
    @Schema(description = "产品名称")
    private String productName;
    @Schema(description = "规格型号")
    private String model;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "不合格数量")
    private BigDecimal quantity;
    @Schema(description = "不合格现象")
    private String defectivePhenomena;
    @Schema(description = "处理结果 æŠ¥åºŸ/让步放行/返修/返工")
    private String dealResult;
    @Schema(description = "处理人")
    private String dealName;
    @Schema(description = "处理时间")
    private LocalDate dealTime;
    @Schema(description = "处理状态 0待处理/1已处理")
    private Integer inspectState;
    @Schema(description = "检验日期")
    private LocalDate checkTime;
}
src/main/java/com/ruoyi/quality/mapper/QualityTraceMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package com.ruoyi.quality.mapper;
import com.ruoyi.quality.dto.QualityTraceBatchOptionVo;
import com.ruoyi.quality.dto.QualityTraceFinishedBatchVo;
import com.ruoyi.quality.dto.QualityTraceInputVo;
import com.ruoyi.quality.dto.QualityTraceInspectVo;
import com.ruoyi.quality.dto.QualityTraceMaterialBatchVo;
import com.ruoyi.quality.dto.QualityTraceOutboundVo;
import com.ruoyi.quality.dto.QualityTracePickVo;
import com.ruoyi.quality.dto.QualityTraceReportVo;
import com.ruoyi.quality.dto.QualityTraceUnqualifiedVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
 * ç”Ÿäº§è´¨é‡è¿½æº¯ï¼ˆåªè¯»é“¾è·¯æŸ¥è¯¢ï¼‰
 */
@Mapper
public interface QualityTraceMapper {
    /** æˆå“æ‰¹å·â†’命中的生产入库记录(报工入库/质检合格入库) */
    List<QualityTraceFinishedBatchVo> listInboundByFinishedBatch(@Param("batchNo") String batchNo);
    /** æŠ¥å·¥å•→报工节点(含订单信息) */
    List<QualityTraceReportVo> listReportsByMainIds(@Param("ids") List<Long> ids);
    /** ç”Ÿäº§è®¢å•→报工节点(含订单信息) */
    List<QualityTraceReportVo> listReportsByOrderIds(@Param("ids") List<Long> ids);
    /** æŠ¥å·¥å•→投入物料 */
    List<QualityTraceInputVo> listInputsByMainIds(@Param("ids") List<Long> ids);
    /** ç”Ÿäº§è®¢å•→领料批次 */
    List<QualityTracePickVo> listPicksByOrderIds(@Param("ids") List<Long> ids);
    /** åŽŸæ–™æ‰¹å·â†’é¢†æ–™æ˜Žç»† */
    List<QualityTracePickVo> listPicksByBatchNo(@Param("batchNo") String batchNo);
    /** æŠ¥å·¥å•→检验单 */
    List<QualityTraceInspectVo> listInspectsByMainIds(@Param("ids") List<Long> ids);
    /** æ£€éªŒå•→不合格处理记录 */
    List<QualityTraceUnqualifiedVo> listUnqualifiedByInspectIds(@Param("ids") List<Long> ids);
    /** æ‰¹å·â†’出库流向 */
    List<QualityTraceOutboundVo> listOutboundByBatch(@Param("batchNo") String batchNo);
    /** ç”Ÿäº§è®¢å•→产出成品批号 */
    List<QualityTraceFinishedBatchVo> listFinishedBatchesByOrderIds(@Param("ids") List<Long> ids);
    /** æ‰¹å·é›†åˆâ†’入库/检验/采购来源信息 */
    List<QualityTraceMaterialBatchVo> listMaterialBatchInfoByBatches(@Param("batchNos") List<String> batchNos);
    /**
     * æ‰¹å·å€™é€‰ï¼ˆå‰ç«¯ä¸‹æ‹‰/搜索)
     *
     * @param type finished=成品批号(生产入库/质检合格入库),material=原料批号(生产领料)
     */
    List<QualityTraceBatchOptionVo> listBatchOptions(@Param("type") String type,
                                                     @Param("keyword") String keyword,
                                                     @Param("limit") Integer limit);
}
src/main/java/com/ruoyi/quality/service/QualityTraceService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package com.ruoyi.quality.service;
import com.ruoyi.quality.dto.QualityTraceBatchOptionVo;
import com.ruoyi.quality.dto.QualityTraceForwardExportVo;
import com.ruoyi.quality.dto.QualityTraceForwardVo;
import com.ruoyi.quality.dto.QualityTraceReverseExportVo;
import com.ruoyi.quality.dto.QualityTraceReverseVo;
import java.util.List;
/**
 * ç”Ÿäº§è´¨é‡è¿½æº¯ï¼šæˆå“æ‰¹å·æ­£å‘追溯、原料批号反向追溯
 */
public interface QualityTraceService {
    /**
     * æ­£å‘追溯:成品批号 â†’ ç”Ÿäº§è®¢å•/报工工序/投入物料/领料批次/检验与不合格处理/出库流向
     */
    QualityTraceForwardVo forward(String batchNo);
    /**
     * åå‘追溯:原料批号 â†’ é‡‡è´­æ¥æº + ä½¿ç”¨è¯¥æ‰¹å·çš„订单 + è®¢å•产出的成品批号
     */
    QualityTraceReverseVo reverse(String batchNo);
    /**
     * æ­£å‘追溯导出(成品批号→原料批号链路行)
     */
    List<QualityTraceForwardExportVo> forwardExportRows(String batchNo);
    /**
     * åå‘追溯导出(原料批号→受影响成品批号行)
     */
    List<QualityTraceReverseExportVo> reverseExportRows(String batchNo);
    /**
     * æ‰¹å·å€™é€‰ï¼ˆå‰ç«¯æœç´¢ä¸‹æ‹‰ï¼‰
     *
     * @param type finished=成品批号,material=原料批号
     */
    List<QualityTraceBatchOptionVo> batchOptions(String type, String keyword, Integer limit);
}
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
@@ -94,6 +94,13 @@
    @Override
    public int submit(QualityInspect inspect) {
        QualityInspect qualityInspect = qualityInspectMapper.selectById(inspect.getId());
        if (qualityInspect == null) {
            throw new ServiceException("检验单不存在");
        }
        //幂等:已提交的检验单不允许重复提交,防止重复生成入库/不合格记录
        if (Integer.valueOf(1).equals(qualityInspect.getInspectState())) {
            throw new ServiceException("检验单已提交,请勿重复提交");
        }
        //提交前必须判断是否合格
        if (ObjectUtils.isNull(qualityInspect.getCheckResult())) {
@@ -116,6 +123,13 @@
        // å¦‚果不合格数量为空,设为0
        if (qualityInspect.getUnqualifiedQuantity() == null) {
            qualityInspect.setUnqualifiedQuantity(BigDecimal.ZERO);
        }
        //数量一致性:合格+不合格 ä¸å¾—超过检验数量
        if (qualityInspect.getQuantity() != null
                && qualityInspect.getQualifiedQuantity().add(qualityInspect.getUnqualifiedQuantity())
                        .compareTo(qualityInspect.getQuantity()) > 0) {
            throw new ServiceException("合格数量与不合格数量之和不能大于检验数量");
        }
        // åˆæ ¼ç›´æŽ¥å…¥åº“
@@ -276,6 +290,14 @@
    @Override
    public int updateQualityInspect(QualityInspectDto qualityInspectDto) {
        QualityInspect exist = qualityInspectMapper.selectById(qualityInspectDto.getId());
        if (exist == null) {
            throw new ServiceException("检验单不存在");
        }
        //已提交的检验单已联动入库/不合格记录,禁止再修改
        if (Integer.valueOf(1).equals(exist.getInspectState())) {
            throw new ServiceException("检验单已提交,不允许修改");
        }
        if (ObjectUtils.isNotNull(qualityInspectDto.getQualityInspectParams())) {
            qualityInspectParamService.remove(Wrappers.<QualityInspectParam>lambdaQuery().eq(QualityInspectParam::getInspectId, qualityInspectDto.getId()));
            for (QualityInspectParam qualityInspectParam : qualityInspectDto.getQualityInspectParams()) {
src/main/java/com/ruoyi/quality/service/impl/QualityTraceServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,347 @@
package com.ruoyi.quality.service.impl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.quality.dto.QualityTraceBatchOptionVo;
import com.ruoyi.quality.dto.QualityTraceChainVo;
import com.ruoyi.quality.dto.QualityTraceFinishedBatchVo;
import com.ruoyi.quality.dto.QualityTraceForwardExportVo;
import com.ruoyi.quality.dto.QualityTraceForwardVo;
import com.ruoyi.quality.dto.QualityTraceInputVo;
import com.ruoyi.quality.dto.QualityTraceInspectVo;
import com.ruoyi.quality.dto.QualityTraceMaterialBatchVo;
import com.ruoyi.quality.dto.QualityTraceOutboundVo;
import com.ruoyi.quality.dto.QualityTracePickVo;
import com.ruoyi.quality.dto.QualityTraceReportVo;
import com.ruoyi.quality.dto.QualityTraceReverseExportVo;
import com.ruoyi.quality.dto.QualityTraceReverseOrderVo;
import com.ruoyi.quality.dto.QualityTraceReverseVo;
import com.ruoyi.quality.dto.QualityTraceUnqualifiedVo;
import com.ruoyi.quality.mapper.QualityTraceMapper;
import com.ruoyi.quality.service.QualityTraceService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class QualityTraceServiceImpl implements QualityTraceService {
    private final QualityTraceMapper qualityTraceMapper;
    @Override
    public QualityTraceForwardVo forward(String batchNo) {
        String no = requireBatchNo(batchNo);
        QualityTraceForwardVo result = new QualityTraceForwardVo();
        result.setBatchNo(no);
        //1.批号命中的生产入库记录(报工入库2 / è´¨æ£€åˆæ ¼å…¥åº“6)
        List<QualityTraceFinishedBatchVo> inbound = qualityTraceMapper.listInboundByFinishedBatch(no);
        if (inbound.isEmpty()) {
            result.setOutbounds(qualityTraceMapper.listOutboundByBatch(no));
            return result;
        }
        Map<Long, List<QualityTraceFinishedBatchVo>> inboundByOrder = inbound.stream()
                .filter(b -> b.getProductionOrderId() != null)
                .collect(Collectors.groupingBy(QualityTraceFinishedBatchVo::getProductionOrderId,
                        LinkedHashMap::new, Collectors.toList()));
        List<Long> mainIds = distinct(inbound.stream().map(QualityTraceFinishedBatchVo::getProductMainId)
                .filter(Objects::nonNull).collect(Collectors.toList()));
        //2.报工节点(含订单信息)
        List<QualityTraceReportVo> reports = mainIds.isEmpty()
                ? new ArrayList<>() : qualityTraceMapper.listReportsByMainIds(mainIds);
        Map<Long, List<QualityTraceReportVo>> reportsByOrder = reports.stream()
                .filter(r -> r.getProductionOrderId() != null)
                .collect(Collectors.groupingBy(QualityTraceReportVo::getProductionOrderId,
                        LinkedHashMap::new, Collectors.toList()));
        Map<Long, Long> orderOfMain = new LinkedHashMap<>();
        reports.forEach(r -> orderOfMain.putIfAbsent(r.getProductMainId(), r.getProductionOrderId()));
        //3.投入物料(按报工单)
        List<QualityTraceInputVo> inputs = mainIds.isEmpty()
                ? new ArrayList<>() : qualityTraceMapper.listInputsByMainIds(mainIds);
        //4.领料批次(按订单),并用原料批号回溯供应商/采购/检验
        List<Long> orderIds = new ArrayList<>(inboundByOrder.keySet());
        List<QualityTracePickVo> picks = orderIds.isEmpty()
                ? new ArrayList<>() : qualityTraceMapper.listPicksByOrderIds(orderIds);
        fillPickSource(picks);
        Map<Long, List<QualityTracePickVo>> picksByOrder = picks.stream()
                .filter(p -> p.getProductionOrderId() != null)
                .collect(Collectors.groupingBy(QualityTracePickVo::getProductionOrderId,
                        LinkedHashMap::new, Collectors.toList()));
        //5.检验单与不合格处理
        List<QualityTraceInspectVo> inspects = mainIds.isEmpty()
                ? new ArrayList<>() : qualityTraceMapper.listInspectsByMainIds(mainIds);
        List<Long> inspectIds = distinct(inspects.stream().map(QualityTraceInspectVo::getInspectId)
                .filter(Objects::nonNull).collect(Collectors.toList()));
        List<QualityTraceUnqualifiedVo> unqualified = inspectIds.isEmpty()
                ? new ArrayList<>() : qualityTraceMapper.listUnqualifiedByInspectIds(inspectIds);
        Map<Long, List<QualityTraceUnqualifiedVo>> unqualifiedByInspect = unqualified.stream()
                .filter(u -> u.getInspectId() != null)
                .collect(Collectors.groupingBy(QualityTraceUnqualifiedVo::getInspectId));
        inspects.forEach(ins -> ins.setUnqualifiedList(
                unqualifiedByInspect.getOrDefault(ins.getInspectId(), new ArrayList<>())));
        Map<Long, List<QualityTraceInspectVo>> inspectsByOrder = inspects.stream()
                .filter(i -> i.getProductMainId() != null && orderOfMain.get(i.getProductMainId()) != null)
                .collect(Collectors.groupingBy(i -> orderOfMain.get(i.getProductMainId()),
                        LinkedHashMap::new, Collectors.toList()));
        Set<Long> chainOrderIds = new LinkedHashSet<>(inboundByOrder.keySet());
        chainOrderIds.addAll(reportsByOrder.keySet());
        for (Long orderId : chainOrderIds) {
            QualityTraceChainVo chain = new QualityTraceChainVo();
            chain.setProductionOrderId(orderId);
            List<QualityTraceReportVo> orderReports =
                    reportsByOrder.getOrDefault(orderId, new ArrayList<>());
            if (!orderReports.isEmpty()) {
                QualityTraceReportVo first = orderReports.get(0);
                chain.setNpsNo(first.getNpsNo());
                chain.setFinishedProduct(first.getFinishedProduct());
                chain.setUnit(first.getUnit());
                chain.setPlanQty(first.getPlanQty());
                chain.setCompleteQty(first.getCompleteQty());
                chain.setOrderStatus(first.getOrderStatus());
                chain.setOrderCreateTime(first.getOrderCreateTime());
            }
            chain.setBatches(inboundByOrder.getOrDefault(orderId, new ArrayList<>()));
            chain.setReports(orderReports);
            Set<Long> orderMainIds = orderReports.stream()
                    .map(QualityTraceReportVo::getProductMainId).filter(Objects::nonNull)
                    .collect(Collectors.toCollection(LinkedHashSet::new));
            chain.setInputs(inputs.stream()
                    .filter(i -> i.getProductMainId() != null && orderMainIds.contains(i.getProductMainId()))
                    .collect(Collectors.toList()));
            chain.setPicks(picksByOrder.getOrDefault(orderId, new ArrayList<>()));
            chain.setInspects(inspectsByOrder.getOrDefault(orderId, new ArrayList<>()));
            chain.setUnqualifiedList(chain.getInspects().stream()
                    .flatMap(i -> i.getUnqualifiedList().stream()).collect(Collectors.toList()));
            result.getChains().add(chain);
        }
        result.setOutbounds(qualityTraceMapper.listOutboundByBatch(no));
        return result;
    }
    @Override
    public QualityTraceReverseVo reverse(String batchNo) {
        String no = requireBatchNo(batchNo);
        QualityTraceReverseVo result = new QualityTraceReverseVo();
        result.setBatchNo(no);
        //1.批号入库/采购/检验来源
        List<QualityTraceMaterialBatchVo> materialInfo =
                qualityTraceMapper.listMaterialBatchInfoByBatches(Collections.singletonList(no));
        if (!materialInfo.isEmpty()) {
            result.setMaterial(materialInfo.get(0));
        }
        //2.使用该批号的领料明细
        List<QualityTracePickVo> picks = qualityTraceMapper.listPicksByBatchNo(no);
        if (picks.isEmpty()) {
            return result;
        }
        fillPickSource(picks);
        Map<Long, List<QualityTracePickVo>> picksByOrder = picks.stream()
                .filter(p -> p.getProductionOrderId() != null)
                .collect(Collectors.groupingBy(QualityTracePickVo::getProductionOrderId,
                        LinkedHashMap::new, Collectors.toList()));
        List<Long> orderIds = new ArrayList<>(picksByOrder.keySet());
        //3.订单报工节点与产出成品批号
        List<QualityTraceReportVo> reports = qualityTraceMapper.listReportsByOrderIds(orderIds);
        Map<Long, List<QualityTraceReportVo>> reportsByOrder = reports.stream()
                .filter(r -> r.getProductionOrderId() != null)
                .collect(Collectors.groupingBy(QualityTraceReportVo::getProductionOrderId,
                        LinkedHashMap::new, Collectors.toList()));
        Map<Long, List<QualityTraceFinishedBatchVo>> batchesByOrder =
                qualityTraceMapper.listFinishedBatchesByOrderIds(orderIds).stream()
                        .filter(b -> b.getProductionOrderId() != null)
                        .collect(Collectors.groupingBy(QualityTraceFinishedBatchVo::getProductionOrderId,
                                LinkedHashMap::new, Collectors.toList()));
        for (Long orderId : orderIds) {
            QualityTraceReverseOrderVo orderVo = new QualityTraceReverseOrderVo();
            orderVo.setProductionOrderId(orderId);
            List<QualityTraceReportVo> orderReports =
                    reportsByOrder.getOrDefault(orderId, new ArrayList<>());
            if (!orderReports.isEmpty()) {
                QualityTraceReportVo first = orderReports.get(0);
                orderVo.setNpsNo(first.getNpsNo());
                orderVo.setFinishedProduct(first.getFinishedProduct());
                orderVo.setUnit(first.getUnit());
                orderVo.setPlanQty(first.getPlanQty());
                orderVo.setCompleteQty(first.getCompleteQty());
                orderVo.setOrderStatus(first.getOrderStatus());
                orderVo.setOrderCreateTime(first.getOrderCreateTime());
            }
            List<QualityTracePickVo> orderPicks =
                    picksByOrder.getOrDefault(orderId, new ArrayList<>());
            orderVo.setPickedQty(orderPicks.stream().map(p -> nvl(p.getQuantity()))
                    .reduce(BigDecimal.ZERO, BigDecimal::add));
            orderVo.setPicks(orderPicks);
            orderVo.setReports(orderReports);
            orderVo.setFinishedBatches(batchesByOrder.getOrDefault(orderId, new ArrayList<>()));
            result.getOrders().add(orderVo);
        }
        return result;
    }
    @Override
    public List<QualityTraceForwardExportVo> forwardExportRows(String batchNo) {
        QualityTraceForwardVo trace = forward(batchNo);
        List<QualityTraceForwardExportVo> rows = new ArrayList<>();
        for (QualityTraceChainVo chain : trace.getChains()) {
            String operations = chain.getReports().stream()
                    .map(r -> StringUtils.isBlank(r.getOperationName()) ? r.getWorkOrderNo() : r.getOperationName())
                    .filter(StringUtils::isNotBlank).distinct().collect(Collectors.joining("/"));
            String inspectResults = chain.getInspects().stream()
                    .map(i -> (StringUtils.isBlank(i.getInspectTypeName()) ? "" : i.getInspectTypeName() + ":")
                            + (StringUtils.isBlank(i.getCheckResult()) ? "未提交" : i.getCheckResult()))
                    .collect(Collectors.joining("/"));
            String unqualifiedResults = chain.getUnqualifiedList().stream()
                    .map(u -> (StringUtils.isBlank(u.getDealResult()) ? "待处理" : u.getDealResult())
                            + "x" + nvl(u.getQuantity()).stripTrailingZeros().toPlainString())
                    .collect(Collectors.joining("/"));
            if (chain.getPicks().isEmpty()) {
                QualityTraceForwardExportVo row = newForwardRow(trace.getBatchNo(), chain, operations,
                        inspectResults, unqualifiedResults);
                rows.add(row);
                continue;
            }
            for (QualityTracePickVo pick : chain.getPicks()) {
                QualityTraceForwardExportVo row = newForwardRow(trace.getBatchNo(), chain, operations,
                        inspectResults, unqualifiedResults);
                row.setMaterialBatchNo(pick.getBatchNo());
                row.setMaterialName(pick.getMaterialName());
                row.setMaterialModel(pick.getMaterialModel());
                row.setPickQty(pick.getQuantity());
                row.setSupplierName(pick.getSupplierName());
                row.setMaterialCheckResult(pick.getCheckResult());
                rows.add(row);
            }
        }
        return rows;
    }
    @Override
    public List<QualityTraceReverseExportVo> reverseExportRows(String batchNo) {
        QualityTraceReverseVo trace = reverse(batchNo);
        List<QualityTraceReverseExportVo> rows = new ArrayList<>();
        QualityTraceMaterialBatchVo material = trace.getMaterial();
        if (trace.getOrders().isEmpty()) {
            rows.add(newReverseRow(trace, material, null, null));
            return rows;
        }
        for (QualityTraceReverseOrderVo order : trace.getOrders()) {
            String batchNos = order.getFinishedBatches().stream()
                    .map(QualityTraceFinishedBatchVo::getBatchNo)
                    .filter(StringUtils::isNotBlank).distinct().collect(Collectors.joining("/"));
            rows.add(newReverseRow(trace, material, order, batchNos));
        }
        return rows;
    }
    private QualityTraceForwardExportVo newForwardRow(String batchNo, QualityTraceChainVo chain,
                                                      String operations, String inspectResults,
                                                      String unqualifiedResults) {
        QualityTraceForwardExportVo row = new QualityTraceForwardExportVo();
        row.setBatchNo(batchNo);
        row.setNpsNo(chain.getNpsNo());
        row.setFinishedProduct(chain.getFinishedProduct());
        row.setCompleteQty(chain.getCompleteQty());
        row.setOperations(operations);
        row.setInspectResults(inspectResults);
        row.setUnqualifiedResults(unqualifiedResults);
        return row;
    }
    private QualityTraceReverseExportVo newReverseRow(QualityTraceReverseVo trace,
                                                      QualityTraceMaterialBatchVo material,
                                                      QualityTraceReverseOrderVo order, String batchNos) {
        QualityTraceReverseExportVo row = new QualityTraceReverseExportVo();
        row.setBatchNo(trace.getBatchNo());
        if (material != null) {
            row.setMaterialName(material.getMaterialName());
            row.setMaterialModel(material.getMaterialModel());
            row.setUnit(material.getUnit());
            row.setSupplierName(material.getSupplierName());
            row.setContractNo(material.getContractNo());
            row.setEntryDate(material.getEntryDate());
            row.setMaterialCheckResult(material.getCheckResult());
        }
        if (order != null) {
            row.setNpsNo(order.getNpsNo());
            row.setFinishedProduct(order.getFinishedProduct());
            row.setPickedQty(order.getPickedQty());
            row.setFinishedBatchNos(batchNos);
        }
        return row;
    }
    @Override
    public List<QualityTraceBatchOptionVo> batchOptions(String type, String keyword, Integer limit) {
        String queryType = "material".equals(type) ? "material" : "finished";
        int size = (limit == null || limit <= 0 || limit > 200) ? 50 : limit;
        return qualityTraceMapper.listBatchOptions(queryType, StringUtils.isBlank(keyword) ? null : keyword.trim(), size);
    }
    /** ç”¨åŽŸæ–™æ‰¹å·å›žæº¯å…¥åº“æ¥æºï¼Œè¡¥å…¨é¢†æ–™è¡Œçš„ä¾›åº”å•†/合同/检验结论 */
    private void fillPickSource(List<QualityTracePickVo> picks) {
        List<String> batchNos = distinct(picks.stream().map(QualityTracePickVo::getBatchNo)
                .filter(StringUtils::isNotBlank).collect(Collectors.toList()));
        if (batchNos.isEmpty()) {
            return;
        }
        Map<String, QualityTraceMaterialBatchVo> infoByBatch = new LinkedHashMap<>();
        for (QualityTraceMaterialBatchVo info : qualityTraceMapper.listMaterialBatchInfoByBatches(batchNos)) {
            if (StringUtils.isNotBlank(info.getBatchNo())) {
                infoByBatch.putIfAbsent(info.getBatchNo(), info);
            }
        }
        for (QualityTracePickVo pick : picks) {
            QualityTraceMaterialBatchVo info = infoByBatch.get(pick.getBatchNo());
            if (info == null) {
                continue;
            }
            pick.setSupplierName(info.getSupplierName());
            pick.setContractNo(info.getContractNo());
            pick.setCheckResult(info.getCheckResult());
            if (StringUtils.isBlank(pick.getMaterialName())) {
                pick.setMaterialName(info.getMaterialName());
            }
            if (StringUtils.isBlank(pick.getMaterialModel())) {
                pick.setMaterialModel(info.getMaterialModel());
            }
            if (StringUtils.isBlank(pick.getUnit())) {
                pick.setUnit(info.getUnit());
            }
        }
    }
    private String requireBatchNo(String batchNo) {
        if (StringUtils.isBlank(batchNo)) {
            throw new ServiceException("批号不能为空");
        }
        return batchNo.trim();
    }
    private <T> List<T> distinct(List<T> ids) {
        return ids.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
    }
    private BigDecimal nvl(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
}
src/main/java/com/ruoyi/quality/service/impl/QualityUnqualifiedServiceImpl.java
@@ -7,6 +7,7 @@
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.procurementrecord.utils.StockUtils;
@@ -30,12 +31,15 @@
import com.ruoyi.stock.service.StockUninventoryService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.servlet.http.HttpServletResponse;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -68,9 +72,28 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deal(QualityUnqualified qualityUnqualified) {
        QualityUnqualified unqualified = qualityUnqualifiedMapper.selectById(qualityUnqualified.getId());
        if (unqualified == null) {
            throw new ServiceException("不合格记录不存在");
        }
        //幂等:已处理的不允许重复处理,防止重复入库/重复报工单
        if (Integer.valueOf(1).equals(unqualified.getInspectState())) {
            throw new ServiceException("该不合格记录已处理,请勿重复操作");
        }
        if (ObjectUtils.isNull(qualityUnqualified.getDealResult())) {
            throw new ServiceException("请选择处理结果");
        }
        QualityInspect qualityInspect = qualityInspectService.getById(unqualified.getInspectId());
        //处理结果枚举校验:原材料仅报废/让步放行;过程/出厂检额外支持返修/返工
        List<String> allowedResults = new ArrayList<>(Arrays.asList("报废", "让步放行"));
        if (qualityInspect == null || qualityInspect.getInspectType() == null || qualityInspect.getInspectType() != 0) {
            allowedResults.addAll(Arrays.asList("返修", "返工"));
        }
        if (!allowedResults.contains(qualityUnqualified.getDealResult())) {
            throw new ServiceException("处理结果不合法,仅支持:" + String.join("/", allowedResults));
        }
        String batchNo = qualityInspect == null ? null
                : resolveProductionBatchNo(qualityInspect.getProductMainId(), qualityInspect.getId(), qualityInspect.getProductModelId());
        if (ObjectUtils.isNotNull(qualityInspect) && qualityInspect.getInspectType() != 0) {
@@ -111,8 +134,14 @@
                    break;
            }
        }
        qualityUnqualified.setInspectState(1);
        return qualityUnqualifiedMapper.updateById(qualityUnqualified);
        //以库中记录为准回写,防止请求体篡改数量/状态等字段
        unqualified.setDealResult(qualityUnqualified.getDealResult());
        unqualified.setDealName(ObjectUtils.isNotNull(qualityUnqualified.getDealName())
                ? qualityUnqualified.getDealName() : SecurityUtils.getUsername());
        unqualified.setDealTime(qualityUnqualified.getDealTime() != null
                ? qualityUnqualified.getDealTime() : new Date());
        unqualified.setInspectState(1);
        return qualityUnqualifiedMapper.updateById(unqualified);
    }
    @Override
src/main/java/com/ruoyi/stock/controller/StockCheckController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
package com.ruoyi.stock.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.stock.dto.StockCheckStartDto;
import com.ruoyi.stock.pojo.StockCheckDetail;
import com.ruoyi.stock.pojo.StockCheckMain;
import com.ruoyi.stock.service.StockCheckService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/stockCheck")
@RequiredArgsConstructor
@Tag(name = "库存盘点")
public class StockCheckController {
    private final StockCheckService stockCheckService;
    @PostMapping("/start")
    @Log(title = "Start stock check", businessType = BusinessType.INSERT)
    @Operation(summary = "发起盘点(生成账面快照明细)")
    public R start(@RequestBody StockCheckStartDto dto) {
        return stockCheckService.start(dto, dto.getProductModelIds());
    }
    @GetMapping("/listPage")
    @Operation(summary = "盘点单分页")
    public R<IPage<StockCheckMain>> listPage(Page<StockCheckMain> page, StockCheckMain query) {
        return R.ok(stockCheckService.listPage(page, query));
    }
    @GetMapping("/{id}")
    @Operation(summary = "盘点单详情(主表)")
    public R<StockCheckMain> getInfo(@PathVariable Long id) {
        return R.ok(stockCheckService.getMain(id));
    }
    @GetMapping("/detailPage")
    @Operation(summary = "盘点明细分页(onlyDiff=true只看已录入且有差异)")
    public R<IPage<StockCheckDetail>> detailPage(Page<StockCheckDetail> page,
                                                 @RequestParam Long mainId,
                                                 @RequestParam(required = false) Boolean onlyDiff) {
        return R.ok(stockCheckService.detailPage(page, mainId, onlyDiff));
    }
    @PutMapping("/entry")
    @Log(title = "Stock check entry", businessType = BusinessType.UPDATE)
    @Operation(summary = "录入实盘数量 [{id,actualQty,remark}...]")
    public R entry(@RequestBody List<StockCheckDetail> details) {
        return stockCheckService.entry(details);
    }
    @PostMapping("/finish")
    @Log(title = "Finish stock check", businessType = BusinessType.UPDATE)
    @Operation(summary = "完成盘点(差异生成待审核调账单据)")
    public R finish(@RequestBody StockCheckMain main) {
        return stockCheckService.finish(main.getId());
    }
    @PostMapping("/cancel")
    @Log(title = "Cancel stock check", businessType = BusinessType.UPDATE)
    @Operation(summary = "取消盘点(仅盘点中)")
    public R cancel(@RequestBody StockCheckMain main) {
        return stockCheckService.cancel(main.getId());
    }
    @DeleteMapping("/delete")
    @Log(title = "Delete stock check", businessType = BusinessType.DELETE)
    @Operation(summary = "删除盘点单(连带删除明细)")
    public R delete(@RequestBody List<Long> ids) {
        return stockCheckService.delete(ids);
    }
    @GetMapping("/diffList")
    @Operation(summary = "差异分析列表")
    public R<List<StockCheckDetail>> diffList(@RequestParam Long mainId) {
        return R.ok(stockCheckService.diffList(mainId));
    }
    @GetMapping("/diffList/export")
    @Log(title = "Export stock check diff", businessType = BusinessType.EXPORT)
    @Operation(summary = "差异分析导出")
    public void diffExport(HttpServletResponse response, @RequestParam Long mainId) {
        List<StockCheckDetail> list = stockCheckService.diffList(mainId);
        ExcelUtil<StockCheckDetail> util = new ExcelUtil<>(StockCheckDetail.class);
        util.exportExcel(response, list, "盘点差异分析");
    }
    @GetMapping("/export")
    @Log(title = "Export stock check", businessType = BusinessType.EXPORT)
    @Operation(summary = "盘点单导出")
    public void export(HttpServletResponse response, StockCheckMain query) {
        List<StockCheckMain> list = stockCheckService.listForExport(query);
        ExcelUtil<StockCheckMain> util = new ExcelUtil<>(StockCheckMain.class);
        util.exportExcel(response, list, "库存盘点单");
    }
    @GetMapping("/detailExport")
    @Log(title = "Export stock check detail", businessType = BusinessType.EXPORT)
    @Operation(summary = "盘点明细导出")
    public void detailExport(HttpServletResponse response, @RequestParam Long mainId) {
        List<StockCheckDetail> list = stockCheckService.detailPage(new Page<>(1, -1), mainId, null).getRecords();
        ExcelUtil<StockCheckDetail> util = new ExcelUtil<>(StockCheckDetail.class);
        util.exportExcel(response, list, "盘点明细");
    }
}
src/main/java/com/ruoyi/stock/controller/StockInventoryController.java
@@ -51,6 +51,35 @@
        return R.ok(stockInventoryDtoIPage);
    }
    @GetMapping("/warnPage")
    @Operation(summary = "库存预警分页(可用数量<=预警值)")
    public R warnPage(Page page, StockInventoryDto stockInventoryDto) {
        stockInventoryDto.setWarnOnly(true);
        return R.ok(stockInventoryService.pagestockInventory(page, stockInventoryDto));
    }
    @GetMapping("/warnExport")
    @Operation(summary = "库存预警导出")
    public void warnExport(HttpServletResponse response, StockInventoryDto stockInventoryDto) {
        stockInventoryDto.setWarnOnly(true);
        List<StockInventoryDto> list = stockInventoryService.pagestockInventory(new Page<>(1, -1), stockInventoryDto).getRecords();
        List<StockInventoryExportData> exportList = new ArrayList<>();
        for (StockInventoryDto item : list) {
            StockInventoryExportData data = new StockInventoryExportData();
            data.setProductName(item.getProductName());
            data.setModel(item.getModel());
            data.setUnit(item.getUnit());
            data.setBatchNo(item.getBatchNo());
            data.setQualifiedQuantity(item.getQualitity());
            data.setWarnNum(item.getWarnNum());
            data.setQualifiedLockedQuantity(item.getLockedQuantity());
            data.setRemark(item.getRemark());
            exportList.add(data);
        }
        ExcelUtil<StockInventoryExportData> util = new ExcelUtil<>(StockInventoryExportData.class);
        util.exportExcel(response, exportList, "库存预警");
    }
    /**
     * æŸ¥è¯¢å¯¹åº”批号和数量
     * @param page
src/main/java/com/ruoyi/stock/dto/StockCheckStartDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.ruoyi.stock.dto;
import com.ruoyi.stock.pojo.StockCheckMain;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(name = "StockCheckStartDto", description = "发起盘点参数")
public class StockCheckStartDto extends StockCheckMain {
    @Schema(description = "指定盘点的产品规格id列表;不传=全部库存")
    private List<Long> productModelIds;
}
src/main/java/com/ruoyi/stock/dto/StockInventoryDto.java
@@ -17,6 +17,9 @@
    private String unit;
    private String batchNo;
    @Schema(description = "true=只查预警(可用数量<=预警值且预警值>0)")
    private Boolean warnOnly;
    //入库类型
    private String recordType;
src/main/java/com/ruoyi/stock/mapper/StockCheckDetailMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package com.ruoyi.stock.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.stock.pojo.StockCheckDetail;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockCheckDetailMapper extends BaseMapper<StockCheckDetail> {
}
src/main/java/com/ruoyi/stock/mapper/StockCheckMainMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package com.ruoyi.stock.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.stock.pojo.StockCheckMain;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockCheckMainMapper extends BaseMapper<StockCheckMain> {
}
src/main/java/com/ruoyi/stock/pojo/StockCheckDetail.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
package com.ruoyi.stock.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * åº“存盘点明细
 */
@Data
@TableName("stock_check_detail")
@Schema(name = "StockCheckDetail", description = "库存盘点明细")
public class StockCheckDetail implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(type = IdType.AUTO)
    private Long id;
    @Schema(description = "盘点主表id")
    private Long mainId;
    @Schema(description = "库存行id")
    private Long inventoryId;
    @Schema(description = "产品规格id")
    private Long productModelId;
    @Excel(name = "产品名称")
    @Schema(description = "产品名称(冗余)")
    private String productName;
    @Excel(name = "规格型号")
    @Schema(description = "规格型号(冗余)")
    private String productModel;
    @Excel(name = "单位")
    @Schema(description = "单位(冗余)")
    private String unit;
    @Excel(name = "批号")
    @Schema(description = "批号")
    private String batchNo;
    @Excel(name = "账面数量")
    @Schema(description = "账面数量快照")
    private BigDecimal bookQty;
    @Excel(name = "实盘数量")
    @Schema(description = "实盘数量")
    private BigDecimal actualQty;
    @Excel(name = "差异数量")
    @Schema(description = "差异=实盘-账面")
    private BigDecimal diffQty;
    @Schema(description = "0未调账 1已生成调账单据")
    private Integer adjusted;
    @Excel(name = "备注")
    @Schema(description = "备注")
    private String remark;
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/stock/pojo/StockCheckMain.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
package com.ruoyi.stock.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * åº“存盘点主表
 */
@Data
@TableName("stock_check_main")
@Schema(name = "StockCheckMain", description = "库存盘点主表")
public class StockCheckMain implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(type = IdType.AUTO)
    private Long id;
    @Excel(name = "盘点单号")
    @Schema(description = "盘点单号")
    private String checkNo;
    @Excel(name = "盘点名称")
    @Schema(description = "盘点名称")
    private String checkName;
    @Excel(name = "盘点日期", width = 30, dateFormat = "yyyy-MM-dd")
    @Schema(description = "盘点日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate checkDate;
    @Excel(name = "盘点人")
    @Schema(description = "盘点人")
    private String checker;
    @Excel(name = "状态", readConverterExp = "0=盘点中,1=已完成,2=已取消")
    @Schema(description = "状态 0盘点中 1已完成 2已取消")
    private Integer status;
    @Excel(name = "盘点种类数")
    @Schema(description = "盘点种类数")
    private Integer totalKinds;
    @Excel(name = "差异种类数")
    @Schema(description = "差异种类数")
    private Integer diffKinds;
    @Excel(name = "备注")
    @Schema(description = "备注")
    private String remark;
    @TableField(exist = false)
    @Schema(description = "创建时间起(查询参数)")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime beginTime;
    @TableField(exist = false)
    @Schema(description = "创建时间止(查询参数)")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime endTime;
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/stock/service/StockCheckService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package com.ruoyi.stock.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.stock.pojo.StockCheckDetail;
import com.ruoyi.stock.pojo.StockCheckMain;
import java.util.List;
public interface StockCheckService {
    /**
     * å‘起盘点:按库存行快照生成盘点明细(可按产品规格过滤)
     */
    R start(StockCheckMain main, List<Long> productModelIds);
    IPage<StockCheckMain> listPage(Page<StockCheckMain> page, StockCheckMain query);
    StockCheckMain getMain(Long id);
    IPage<StockCheckDetail> detailPage(Page<StockCheckDetail> page, Long mainId, Boolean onlyDiff);
    /**
     * å½•入实盘数量:[{id, actualQty, remark}...]
     */
    R entry(List<StockCheckDetail> details);
    /**
     * å®Œæˆç›˜ç‚¹ï¼šå·®å¼‚生成待审核出入库调账单据
     */
    R finish(Long id);
    R cancel(Long id);
    /**
     * åˆ é™¤ç›˜ç‚¹å•(连带删除明细)
     */
    R delete(List<Long> ids);
    /**
     * å·®å¼‚分析列表(含未录入)
     */
    List<StockCheckDetail> diffList(Long mainId);
    List<StockCheckMain> listForExport(StockCheckMain query);
}
src/main/java/com/ruoyi/stock/service/impl/StockCheckServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,336 @@
package com.ruoyi.stock.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.Product;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockCheckDetailMapper;
import com.ruoyi.stock.mapper.StockCheckMainMapper;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockCheckDetail;
import com.ruoyi.stock.pojo.StockCheckMain;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.service.StockCheckService;
import com.ruoyi.stock.service.StockInventoryService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(rollbackFor = Exception.class)
public class StockCheckServiceImpl implements StockCheckService {
    private final StockCheckMainMapper stockCheckMainMapper;
    private final StockCheckDetailMapper stockCheckDetailMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    private final ProductModelMapper productModelMapper;
    private final ProductMapper productMapper;
    @Override
    public R start(StockCheckMain main, List<Long> productModelIds) {
        if (main.getCheckName() == null || main.getCheckName().trim().isEmpty()) {
            throw new ServiceException("盘点名称不能为空");
        }
        List<StockInventory> inventories = stockInventoryMapper.selectList(
                Wrappers.<StockInventory>lambdaQuery()
                        .in(productModelIds != null && !productModelIds.isEmpty(),
                                StockInventory::getProductModelId,
                                productModelIds == null ? List.of() : productModelIds));
        if (inventories.isEmpty()) {
            throw new ServiceException("没有可盘点的库存记录");
        }
        //同一产品规格不允许同时在两张盘点中
        List<Long> modelIds = inventories.stream().map(StockInventory::getProductModelId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        List<Long> pendingIds = stockCheckDetailMapper.selectList(
                        Wrappers.<StockCheckDetail>lambdaQuery()
                                .select(StockCheckDetail::getProductModelId)
                                .in(StockCheckDetail::getProductModelId, modelIds)
                                .eq(StockCheckDetail::getAdjusted, 0))
                .stream().map(StockCheckDetail::getProductModelId).distinct().collect(Collectors.toList());
        List<Long> conflictModelIds = new ArrayList<>();
        if (!pendingIds.isEmpty()) {
            List<StockCheckMain> pendingMains = stockCheckMainMapper.selectList(
                    Wrappers.<StockCheckMain>lambdaQuery()
                            .select(StockCheckMain::getId)
                            .in(StockCheckMain::getStatus, 0));
            if (!pendingMains.isEmpty()) {
                List<Long> pendingMainIds = pendingMains.stream().map(StockCheckMain::getId).collect(Collectors.toList());
                conflictModelIds = stockCheckDetailMapper.selectList(
                                Wrappers.<StockCheckDetail>lambdaQuery()
                                        .select(StockCheckDetail::getProductModelId)
                                        .in(StockCheckDetail::getMainId, pendingMainIds)
                                        .in(StockCheckDetail::getProductModelId, pendingIds))
                        .stream().map(StockCheckDetail::getProductModelId).distinct().collect(Collectors.toList());
            }
        }
        if (!conflictModelIds.isEmpty()) {
            throw new ServiceException("以下产品规格存在盘点中的单据,请先完成或取消:" + joinModels(conflictModelIds));
        }
        main.setCheckNo(generateCheckNo());
        main.setStatus(0);
        if (main.getCheckDate() == null) {
            main.setCheckDate(LocalDate.now());
        }
        main.setTotalKinds(inventories.size());
        main.setDiffKinds(0);
        stockCheckMainMapper.insert(main);
        Map<Long, ProductModel> modelMap = loadModels(inventories);
        Map<Long, Product> productMap = loadProducts(modelMap.values());
        for (StockInventory inv : inventories) {
            StockCheckDetail detail = new StockCheckDetail();
            detail.setMainId(main.getId());
            detail.setInventoryId(inv.getId());
            detail.setProductModelId(inv.getProductModelId());
            ProductModel pm = modelMap.get(inv.getProductModelId());
            if (pm != null) {
                detail.setProductModel(pm.getModel());
                detail.setUnit(pm.getUnit());
                Product p = productMap.get(pm.getProductId());
                if (p != null) {
                    detail.setProductName(p.getProductName());
                }
            }
            detail.setBatchNo(inv.getBatchNo());
            detail.setBookQty(inv.getQualitity());
            detail.setAdjusted(0);
            stockCheckDetailMapper.insert(detail);
        }
        return R.ok(main.getId());
    }
    @Override
    public IPage<StockCheckMain> listPage(Page<StockCheckMain> page, StockCheckMain query) {
        return stockCheckMainMapper.selectPage(page, Wrappers.<StockCheckMain>lambdaQuery()
                .like(query.getCheckNo() != null && !query.getCheckNo().isEmpty(),
                        StockCheckMain::getCheckNo, query.getCheckNo())
                .like(query.getCheckName() != null && !query.getCheckName().isEmpty(),
                        StockCheckMain::getCheckName, query.getCheckName())
                .eq(query.getStatus() != null, StockCheckMain::getStatus, query.getStatus())
                .ge(query.getBeginTime() != null, StockCheckMain::getCreateTime, query.getBeginTime())
                .le(query.getEndTime() != null, StockCheckMain::getCreateTime, query.getEndTime())
                .orderByDesc(StockCheckMain::getId));
    }
    @Override
    public StockCheckMain getMain(Long id) {
        StockCheckMain main = stockCheckMainMapper.selectById(id);
        if (main == null) {
            throw new ServiceException("盘点单不存在");
        }
        return main;
    }
    @Override
    public IPage<StockCheckDetail> detailPage(Page<StockCheckDetail> page, Long mainId, Boolean onlyDiff) {
        getMain(mainId);
        return stockCheckDetailMapper.selectPage(page, Wrappers.<StockCheckDetail>lambdaQuery()
                .eq(StockCheckDetail::getMainId, mainId)
                .isNotNull(Boolean.TRUE.equals(onlyDiff), StockCheckDetail::getActualQty)
                .apply(Boolean.TRUE.equals(onlyDiff),
                        "actual_qty is not null and ifnull(actual_qty,0) <> ifnull(book_qty,0)")
                .orderByAsc(StockCheckDetail::getId));
    }
    @Override
    public R entry(List<StockCheckDetail> details) {
        if (details == null || details.isEmpty()) {
            throw new ServiceException("请传入实盘数据");
        }
        for (StockCheckDetail item : details) {
            if (item.getId() == null || item.getActualQty() == null
                    || item.getActualQty().compareTo(BigDecimal.ZERO) < 0) {
                throw new ServiceException("实盘数据错误:id与实盘数量(>=0)必传");
            }
            StockCheckDetail db = stockCheckDetailMapper.selectById(item.getId());
            if (db == null) {
                throw new ServiceException("盘点明细不存在,ID=" + item.getId());
            }
            StockCheckMain main = getMain(db.getMainId());
            if (!Integer.valueOf(0).equals(main.getStatus())) {
                throw new ServiceException("盘点单[" + main.getCheckNo() + "]已结束,不能再录入");
            }
            StockCheckDetail update = new StockCheckDetail();
            update.setId(db.getId());
            update.setActualQty(item.getActualQty());
            update.setDiffQty(item.getActualQty().subtract(
                    db.getBookQty() == null ? BigDecimal.ZERO : db.getBookQty()));
            if (item.getRemark() != null) {
                update.setRemark(item.getRemark());
            }
            stockCheckDetailMapper.updateById(update);
        }
        return R.ok();
    }
    @Override
    public R finish(Long id) {
        StockCheckMain main = getMain(id);
        if (!Integer.valueOf(0).equals(main.getStatus())) {
            throw new ServiceException("仅盘点中的单据可以完成");
        }
        List<StockCheckDetail> details = stockCheckDetailMapper.selectList(
                Wrappers.<StockCheckDetail>lambdaQuery().eq(StockCheckDetail::getMainId, id));
        if (details.isEmpty()) {
            throw new ServiceException("盘点明细为空");
        }
        List<StockCheckDetail> notEntered = details.stream()
                .filter(d -> d.getActualQty() == null).collect(Collectors.toList());
        if (!notEntered.isEmpty()) {
            throw new ServiceException("还有 " + notEntered.size() + " æ¡æ˜Žç»†æœªå½•入实盘数量,请先录满");
        }
        int diffKinds = 0;
        int adjusted = 0;
        for (StockCheckDetail detail : details) {
            BigDecimal diff = detail.getDiffQty() == null ? BigDecimal.ZERO : detail.getDiffQty();
            if (diff.compareTo(BigDecimal.ZERO) == 0) {
                continue;
            }
            diffKinds++;
            StockInventoryDto dto = new StockInventoryDto();
            dto.setRecordId(detail.getId());
            dto.setProductModelId(detail.getProductModelId());
            dto.setBatchNo(detail.getBatchNo());
            dto.setQualitity(diff.abs());
            dto.setRemark("盘点调账 " + main.getCheckNo());
            if (diff.compareTo(BigDecimal.ZERO) > 0) {
                //盘盈:入库
                dto.setRecordType(StockInQualifiedRecordTypeEnum.CUSTOMIZATION_STOCK_IN.getCode());
                stockInventoryService.addStockInRecordOnly(dto);
            } else {
                //盘亏:出库(超可用库存会被出库校验拦截)
                dto.setRecordType(StockOutQualifiedRecordTypeEnum.CUSTOMIZATION_STOCK_OUT.getCode());
                stockInventoryService.addStockOutRecordOnly(dto);
            }
            StockCheckDetail update = new StockCheckDetail();
            update.setId(detail.getId());
            update.setAdjusted(1);
            stockCheckDetailMapper.updateById(update);
            adjusted++;
        }
        StockCheckMain update = new StockCheckMain();
        update.setId(id);
        update.setStatus(1);
        update.setDiffKinds(diffKinds);
        stockCheckMainMapper.updateById(update);
        return R.ok("盘点完成:差异 " + diffKinds + " æ¡ï¼Œå·²ç”Ÿæˆè°ƒè´¦å•据 " + adjusted
                + " æ¡ï¼ˆå¾…出入库记录审核通过后账面生效)");
    }
    @Override
    public R cancel(Long id) {
        StockCheckMain main = getMain(id);
        if (!Integer.valueOf(0).equals(main.getStatus())) {
            throw new ServiceException("仅盘点中的单据可以取消");
        }
        StockCheckMain update = new StockCheckMain();
        update.setId(id);
        update.setStatus(2);
        stockCheckMainMapper.updateById(update);
        return R.ok();
    }
    @Override
    public R delete(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("请选择至少一条数据");
        }
        stockCheckDetailMapper.delete(Wrappers.<StockCheckDetail>lambdaQuery()
                .in(StockCheckDetail::getMainId, ids));
        stockCheckMainMapper.deleteBatchIds(ids);
        return R.ok();
    }
    @Override
    public List<StockCheckDetail> diffList(Long mainId) {
        getMain(mainId);
        return stockCheckDetailMapper.selectList(Wrappers.<StockCheckDetail>lambdaQuery()
                .eq(StockCheckDetail::getMainId, mainId)
                .isNotNull(StockCheckDetail::getActualQty)
                .apply("ifnull(actual_qty,0) <> ifnull(book_qty,0)")
                .orderByAsc(StockCheckDetail::getId));
    }
    @Override
    public List<StockCheckMain> listForExport(StockCheckMain query) {
        return stockCheckMainMapper.selectList(Wrappers.<StockCheckMain>lambdaQuery()
                .like(query != null && query.getCheckNo() != null && !query.getCheckNo().isEmpty(),
                        StockCheckMain::getCheckNo, query == null ? null : query.getCheckNo())
                .like(query != null && query.getCheckName() != null && !query.getCheckName().isEmpty(),
                        StockCheckMain::getCheckName, query == null ? null : query.getCheckName())
                .eq(query != null && query.getStatus() != null,
                        StockCheckMain::getStatus, query == null ? null : query.getStatus())
                .orderByDesc(StockCheckMain::getId));
    }
    private String generateCheckNo() {
        String prefix = "PD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        StockCheckMain latest = stockCheckMainMapper.selectOne(Wrappers.<StockCheckMain>lambdaQuery()
                .select(StockCheckMain::getCheckNo)
                .likeRight(StockCheckMain::getCheckNo, prefix)
                .orderByDesc(StockCheckMain::getCheckNo)
                .last("limit 1"), false);
        int sequence = 1;
        if (latest != null && latest.getCheckNo() != null && latest.getCheckNo().startsWith(prefix)) {
            try {
                sequence = Integer.parseInt(latest.getCheckNo().substring(prefix.length())) + 1;
            } catch (NumberFormatException ignored) {
                sequence = 1;
            }
        }
        return prefix + String.format("%03d", sequence);
    }
    private Map<Long, ProductModel> loadModels(List<StockInventory> inventories) {
        List<Long> ids = inventories.stream().map(StockInventory::getProductModelId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (ids.isEmpty()) {
            return new HashMap<>();
        }
        return productModelMapper.selectBatchIds(ids).stream()
                .collect(Collectors.toMap(ProductModel::getId, m -> m, (a, b) -> a));
    }
    private Map<Long, Product> loadProducts(java.util.Collection<ProductModel> models) {
        List<Long> ids = models.stream().map(ProductModel::getProductId)
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (ids.isEmpty()) {
            return new HashMap<>();
        }
        return productMapper.selectBatchIds(ids).stream()
                .collect(Collectors.toMap(Product::getId, p -> p, (a, b) -> a));
    }
    private String joinModels(List<Long> modelIds) {
        Map<Long, ProductModel> modelMap = productModelMapper.selectBatchIds(modelIds).stream()
                .collect(Collectors.toMap(ProductModel::getId, m -> m, (a, b) -> a));
        return modelIds.stream()
                .map(id -> {
                    ProductModel pm = modelMap.get(id);
                    return pm == null || pm.getModel() == null ? String.valueOf(id) : pm.getModel();
                })
                .collect(Collectors.joining("、"));
    }
}
src/main/java/com/ruoyi/technology/bean/dto/BomKitCheckDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.ruoyi.technology.bean.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(description = "BOM齐套分析入参")
public class BomKitCheckDto {
    @Schema(description = "BOM id")
    private Long bomId;
    @Schema(description = "需求数量(以BOM根产品为单位)")
    private BigDecimal demandQty;
}
src/main/java/com/ruoyi/technology/bean/vo/BomKitCheckItemVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.ruoyi.technology.bean.vo;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(description = "BOM齐套分析行结果")
public class BomKitCheckItemVo {
    @Schema(description = "产品规格id")
    private Long productModelId;
    @Excel(name = "产品名称")
    @Schema(description = "产品名称")
    private String productName;
    @Excel(name = "规格型号")
    @Schema(description = "规格型号")
    private String model;
    @Excel(name = "单位")
    @Schema(description = "单位")
    private String unit;
    @Excel(name = "需求量")
    @Schema(description = "按BOM展开后的需求数量")
    private BigDecimal requiredQty;
    @Excel(name = "可用库存")
    @Schema(description = "库存数量-锁定数量")
    private BigDecimal availableQty;
    @Excel(name = "缺口数量")
    @Schema(description = "需求量-可用库存,不足部分,齐套时为0")
    private BigDecimal shortageQty;
}
src/main/java/com/ruoyi/technology/controller/TechnologyBomController.java
@@ -7,7 +7,9 @@
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.technology.bean.dto.BomImportDto;
import com.ruoyi.technology.bean.dto.BomKitCheckDto;
import com.ruoyi.technology.bean.dto.TechnologyBomDto;
import com.ruoyi.technology.bean.vo.BomKitCheckItemVo;
import com.ruoyi.technology.bean.vo.TechnologyBomVo;
import com.ruoyi.technology.pojo.TechnologyBom;
import com.ruoyi.technology.service.TechnologyBomService;
@@ -94,4 +96,33 @@
    public R copy(@RequestBody TechnologyBom technologyBom) {
        return technologyBomService.copy(technologyBom);
    }
    @PostMapping("/kitCheck")
    @Operation(summary = "BOM齐套分析(需求展开+库存比对)")
    public R<List<BomKitCheckItemVo>> kitCheck(@RequestBody BomKitCheckDto dto) {
        return R.ok(technologyBomService.kitCheck(dto));
    }
    @PostMapping("/kitCheck/export")
    @Log(title = "Export BOM kit check", businessType = BusinessType.EXPORT)
    @Operation(summary = "BOM齐套分析导出Excel")
    public void exportKitCheck(HttpServletResponse response, @RequestBody BomKitCheckDto dto) {
        List<BomKitCheckItemVo> list = technologyBomService.kitCheck(dto);
        ExcelUtil<BomKitCheckItemVo> util = new ExcelUtil<>(BomKitCheckItemVo.class);
        util.exportExcel(response, list, "BOM齐套分析");
    }
    @PostMapping("/kitCheck/generateDemand")
    @Log(title = "Generate procurement demand", businessType = BusinessType.INSERT)
    @Operation(summary = "齐套缺口生成采购需求")
    public R generateDemand(@RequestBody BomKitCheckDto dto) {
        return technologyBomService.generateProcurementDemand(dto);
    }
    @PutMapping("/changeStatus")
    @Log(title = "Change BOM status", businessType = BusinessType.UPDATE)
    @Operation(summary = "BOM启用/停用(启用自动停用同规格其它版本)")
    public R changeStatus(@RequestBody TechnologyBom technologyBom) {
        return technologyBomService.changeStatus(technologyBom.getId(), technologyBom.getStatus());
    }
}
src/main/java/com/ruoyi/technology/pojo/TechnologyBom.java
@@ -35,6 +35,9 @@
    @Schema(description = "版本号")
    private String version;
    @Schema(description = "状态 1启用 0停用;同一产品规格仅允许一个启用版本")
    private Integer status;
    @Schema(description = "创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
src/main/java/com/ruoyi/technology/service/TechnologyBomService.java
@@ -4,7 +4,9 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.technology.bean.dto.BomKitCheckDto;
import com.ruoyi.technology.bean.dto.TechnologyBomDto;
import com.ruoyi.technology.bean.vo.BomKitCheckItemVo;
import com.ruoyi.technology.bean.vo.TechnologyBomVo;
import com.ruoyi.technology.pojo.TechnologyBom;
import jakarta.servlet.http.HttpServletResponse;
@@ -29,4 +31,19 @@
    void exportBom(HttpServletResponse response, Long bomId);
    R copy(TechnologyBom technologyBom);
    /**
     * BOM齐套分析:按需求数量展开BOM,比对可用库存(数量-锁定量),返回各物料需求/可用/缺口
     */
    List<BomKitCheckItemVo> kitCheck(BomKitCheckDto dto);
    /**
     * é½å¥—缺口生成/累加采购需求(procurement_demand å¾…处理记录)
     */
    R generateProcurementDemand(BomKitCheckDto dto);
    /**
     * BOM启用/停用;启用时自动停用同产品规格的其它版本
     */
    R changeStatus(Integer id, Integer status);
}
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java
@@ -15,9 +15,15 @@
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.procurementrecord.pojo.ProcurementDemand;
import com.ruoyi.procurementrecord.service.ProcurementDemandService;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.technology.bean.dto.BomImportDto;
import com.ruoyi.technology.bean.dto.BomKitCheckDto;
import com.ruoyi.technology.bean.dto.TechnologyBomDto;
import com.ruoyi.technology.bean.dto.TechnologyBomStructureDto;
import com.ruoyi.technology.bean.vo.BomKitCheckItemVo;
import com.ruoyi.technology.bean.vo.TechnologyBomStructureVo;
import com.ruoyi.technology.bean.vo.TechnologyBomVo;
import com.ruoyi.technology.mapper.TechnologyBomMapper;
@@ -49,6 +55,8 @@
    private final TechnologyRoutingMapper technologyRoutingMapper;
    private final IProductService productService;
    private final TechnologyBomStructureMapper technologyBomStructureMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final ProcurementDemandService procurementDemandService;
    /**
     * åˆ†é¡µæŸ¥è¯¢BOM列表。
@@ -90,12 +98,18 @@
    @Transactional(rollbackFor = Exception.class)
    public R add(TechnologyBom technologyBom) {
        validateProductModel(technologyBom.getProductModelId());
        if (technologyBom.getStatus() == null) {
            technologyBom.setStatus(1);
        }
        boolean saved = technologyBomMapper.insert(technologyBom) > 0;
        if (!saved) {
            return R.fail("Add BOM failed");
        }
        technologyBom.setBomNo("BM." + String.format("%05d", technologyBom.getId()));
        technologyBomMapper.updateById(technologyBom);
        if (Integer.valueOf(1).equals(technologyBom.getStatus())) {
            deactivateOtherVersions(technologyBom.getProductModelId(), technologyBom.getId().longValue());
        }
        initRootStructure(technologyBom.getId().longValue(), technologyBom.getProductModelId());
        return R.ok();
    }
@@ -124,7 +138,24 @@
            technologyBom.setBomNo(oldBom.getBomNo());
        }
        technologyBomMapper.updateById(technologyBom);
        if (Integer.valueOf(1).equals(technologyBom.getStatus())) {
            deactivateOtherVersions(technologyBom.getProductModelId(), technologyBom.getId().longValue());
        }
        return R.ok();
    }
    /**
     * åŒä¸€äº§å“è§„格只保留一个启用版本
     */
    private void deactivateOtherVersions(Long productModelId, Long keepId) {
        if (productModelId == null) {
            return;
        }
        technologyBomMapper.update(null, Wrappers.<TechnologyBom>lambdaUpdate()
                .set(TechnologyBom::getStatus, 0)
                .eq(TechnologyBom::getProductModelId, productModelId)
                .ne(TechnologyBom::getId, keepId)
                .eq(TechnologyBom::getStatus, 1));
    }
    /**
@@ -341,6 +372,7 @@
        newTechnologyBom.setProductModelId(oldTechnologyBom.getProductModelId());
        newTechnologyBom.setVersion("FZ" + oldTechnologyBom.getVersion());
        newTechnologyBom.setRemark(oldTechnologyBom.getRemark());
        newTechnologyBom.setStatus(0);
        boolean saved = technologyBomMapper.insert(newTechnologyBom) > 0;
        if (!saved) {
            return R.fail("Copy BOM failed");
@@ -483,4 +515,149 @@
        long randomNum = ThreadLocalRandom.current().nextLong(min, max + 1);
        return String.valueOf(randomNum);
    }
    /**
     * BOM齐套分析:逐层展开树(需求量=父需求×单位用量),按规格汇总后与可用库存比对。
     * å¯ç”¨åº“å­˜ = Î£(库存数量 - é”å®šæ•°é‡),缺口 = max(需求量 - å¯ç”¨åº“å­˜, 0)。
     */
    @Override
    public List<BomKitCheckItemVo> kitCheck(BomKitCheckDto dto) {
        if (dto == null || dto.getBomId() == null || dto.getDemandQty() == null
                || dto.getDemandQty().compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("请传入bomId和需求数量(须大于0)");
        }
        TechnologyBom bom = technologyBomMapper.selectById(dto.getBomId());
        if (bom == null) {
            throw new ServiceException("BOM不存在");
        }
        List<TechnologyBomStructureVo> roots = technologyBomStructureService.listByBomId(dto.getBomId());
        if (CollUtil.isEmpty(roots)) {
            throw new ServiceException("BOM结构为空,请先维护物料清单");
        }
        Map<Long, BigDecimal> requiredMap = new LinkedHashMap<>();
        for (TechnologyBomStructureVo root : roots) {
            expandRequirements(root, dto.getDemandQty(), requiredMap, true);
        }
        if (requiredMap.isEmpty()) {
            throw new ServiceException("BOM下无物料节点");
        }
        Map<Long, BigDecimal> availMap = new HashMap<>();
        List<StockInventory> stocks = stockInventoryMapper.selectList(Wrappers.<StockInventory>lambdaQuery()
                .in(StockInventory::getProductModelId, requiredMap.keySet()));
        for (StockInventory s : stocks) {
            BigDecimal avail = s.getQualitity() == null ? BigDecimal.ZERO : s.getQualitity();
            avail = avail.subtract(s.getLockedQuantity() == null ? BigDecimal.ZERO : s.getLockedQuantity());
            availMap.merge(s.getProductModelId(), avail, BigDecimal::add);
        }
        List<ProductModel> models = productModelService.listByIds(requiredMap.keySet());
        Map<Long, ProductModel> modelMap = models.stream()
                .collect(Collectors.toMap(ProductModel::getId, m -> m, (a, b) -> a));
        Map<Long, String> productNameMap = new HashMap<>();
        Set<Long> productIds = models.stream().map(ProductModel::getProductId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        if (!productIds.isEmpty()) {
            productService.listByIds(productIds)
                    .forEach(p -> productNameMap.put(p.getId().longValue(), p.getProductName()));
        }
        List<BomKitCheckItemVo> result = new ArrayList<>();
        for (Map.Entry<Long, BigDecimal> entry : requiredMap.entrySet()) {
            BigDecimal required = entry.getValue();
            BigDecimal available = availMap.getOrDefault(entry.getKey(), BigDecimal.ZERO);
            ProductModel pm = modelMap.get(entry.getKey());
            BomKitCheckItemVo vo = new BomKitCheckItemVo();
            vo.setProductModelId(entry.getKey());
            vo.setProductName(pm == null ? null : productNameMap.get(pm.getProductId()));
            vo.setModel(pm == null ? null : pm.getModel());
            vo.setUnit(pm == null ? null : pm.getUnit());
            vo.setRequiredQty(required);
            vo.setAvailableQty(available);
            vo.setShortageQty(required.subtract(available).max(BigDecimal.ZERO));
            result.add(vo);
        }
        result.sort(Comparator.comparing(BomKitCheckItemVo::getShortageQty).reversed());
        return result;
    }
    private void expandRequirements(TechnologyBomStructureVo node, BigDecimal requiredQty,
                                    Map<Long, BigDecimal> requiredMap, boolean isRoot) {
        if (!isRoot && node.getProductModelId() != null) {
            requiredMap.merge(node.getProductModelId(), requiredQty, BigDecimal::add);
        }
        if (CollUtil.isNotEmpty(node.getChildren())) {
            for (TechnologyBomStructureVo child : node.getChildren()) {
                BigDecimal unitQty = child.getUnitQuantity() == null ? BigDecimal.ZERO : child.getUnitQuantity();
                expandRequirements(child, requiredQty.multiply(unitQty), requiredMap, false);
            }
        }
    }
    /**
     * å°†ç¼ºå£ç‰©æ–™å†™å…¥é‡‡è´­éœ€æ±‚:同规格已存在待处理记录则累加缺口,否则新增。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R generateProcurementDemand(BomKitCheckDto dto) {
        List<BomKitCheckItemVo> items = kitCheck(dto);
        List<BomKitCheckItemVo> shortages = items.stream()
                .filter(item -> item.getShortageQty().compareTo(BigDecimal.ZERO) > 0)
                .collect(Collectors.toList());
        if (shortages.isEmpty()) {
            return R.ok("库存齐套,无需生成采购需求");
        }
        TechnologyBom bom = technologyBomMapper.selectById(dto.getBomId());
        int created = 0;
        int merged = 0;
        for (BomKitCheckItemVo item : shortages) {
            ProcurementDemand exist = procurementDemandService.getOne(Wrappers.<ProcurementDemand>lambdaQuery()
                    .eq(ProcurementDemand::getProductModelId, item.getProductModelId())
                    .eq(ProcurementDemand::getStatus, 0)
                    .last("limit 1"));
            if (exist != null) {
                exist.setDemandQuantity(exist.getDemandQuantity().add(item.getShortageQty()));
                exist.setStockQuantity(item.getAvailableQty());
                exist.setSourceBomId(dto.getBomId());
                exist.setSourceBomNo(bom == null ? null : bom.getBomNo());
                procurementDemandService.updateById(exist);
                merged++;
            } else {
                ProcurementDemand demand = new ProcurementDemand();
                demand.setProductModelId(item.getProductModelId());
                demand.setProductName(item.getProductName());
                demand.setProductModel(item.getModel());
                demand.setUnit(item.getUnit());
                demand.setDemandQuantity(item.getShortageQty());
                demand.setStockQuantity(item.getAvailableQty());
                demand.setSourceBomId(dto.getBomId());
                demand.setSourceBomNo(bom == null ? null : bom.getBomNo());
                demand.setStatus(0);
                procurementDemandService.save(demand);
                created++;
            }
        }
        return R.ok(String.format("采购需求生成完成:新增 %d æ¡ï¼Œç´¯åŠ åˆå¹¶ %d æ¡", created, merged));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public R changeStatus(Integer id, Integer status) {
        if (id == null || status == null || (status != 0 && status != 1)) {
            return R.fail("参数错误:需传入id和状态(1启用/0停用)");
        }
        TechnologyBom bom = technologyBomMapper.selectById(id);
        if (bom == null) {
            return R.fail("BOM不存在");
        }
        TechnologyBom update = new TechnologyBom();
        update.setId(id);
        update.setStatus(status);
        technologyBomMapper.updateById(update);
        if (Integer.valueOf(1).equals(status)) {
            deactivateOtherVersions(bom.getProductModelId(), id.longValue());
        }
        return R.ok();
    }
}
src/main/resources/application-dev.yml
@@ -74,7 +74,7 @@
    druid:
      # ä¸»åº“数据源
      master:
        url: jdbc:mysql://localhost:3306/product-inventory-management-bdpro?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        url: jdbc:mysql://localhost:3306/product-inventory-management-qysj?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        username: root
        password: 123456
      # ä»Žåº“数据源
src/main/resources/mapper/basic/ProductModelMapper.xml
@@ -12,6 +12,8 @@
        <result column="tenant_id" property="tenantId" />
        <result column="product_name" property="productName" />
        <result column="product_id" property="productId" />
        <result column="product_code" property="productCode" />
        <result column="barcode" property="barcode" />
    </resultMap>
    <resultMap id="ProductModelVoResultMap" type="com.ruoyi.basic.vo.ProductModelVo" extends="BaseResultMap">
@@ -30,6 +32,9 @@
            <if test="c.model != null and c.model != ''">
                and pm.model  like  concat('%',#{c.model},'%')
            </if>
            <if test="c.barcode != null and c.barcode != ''">
                and pm.barcode  like  concat('%',#{c.barcode},'%')
            </if>
            <if test="c.productName != null and c.productName != ''">
                and p.product_name  like  concat('%',#{c.productName},'%')
            </if>
src/main/resources/mapper/cost/CostAccountingMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,185 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.cost.mapper.CostAccountingMapper">
    <!-- ç»Ÿä¸€çš„订单筛选条件:po=生产订单,pm/p=订单成品规格/产品
         è®¢å•日期取 create_time,历史数据未回填时回退 start_time,避免筛选丢单 -->
    <sql id="orderFilter">
        <if test="startDate != null">and date(coalesce(po.create_time, po.start_time)) &gt;= #{startDate}</if>
        <if test="endDate != null">and date(coalesce(po.create_time, po.start_time)) &lt;= #{endDate}</if>
        <if test="npsNo != null and npsNo != ''">and po.nps_no like concat('%', #{npsNo}, '%')</if>
        <if test="productName != null and productName != ''">
            and (p.product_name like concat('%', #{productName}, '%')
                or pm.model like concat('%', #{productName}, '%'))
        </if>
    </sql>
    <select id="listOrders" resultType="com.ruoyi.cost.bean.vo.CostOrderVo">
        select po.id                                                        as productionOrderId,
               po.nps_no                                                    as npsNo,
               po.product_model_id                                          as productModelId,
               concat(ifnull(p.product_name, ''), '/', ifnull(pm.model, '')) as finishedProduct,
               ifnull(p.product_name, '')                                   as productName,
               ifnull(pm.model, '')                                         as productModel,
               pm.unit                                                      as unit,
               ifnull(po.complete_quantity, 0)                              as completeQty,
               ifnull(po.quantity, 0)                                       as planQty,
               coalesce(po.create_time, po.start_time)                      as createTime
        from production_order po
                 left join product_model pm on pm.id = po.product_model_id
                 left join product p on p.id = pm.product_id
        <where>
            <include refid="orderFilter"/>
        </where>
        order by po.id desc
    </select>
    <select id="listMaterialInputs" resultType="com.ruoyi.cost.bean.vo.CostMaterialInputVo">
        select pot.production_order_id            as productionOrderId,
               ppi.product_model_id               as productModelId,
               sum(ifnull(ppi.input_quantity, 0)) as inputQty
        from production_product_input ppi
                 join production_product_main ppm on ppm.id = ppi.production_product_main_id
                 join production_operation_task pot on pot.id = ppm.production_operation_task_id
                 join production_order po on po.id = pot.production_order_id
                 left join product_model pm on pm.id = po.product_model_id
                 left join product p on p.id = pm.product_id
        <where>
            and ppi.product_model_id is not null
            <include refid="orderFilter"/>
        </where>
        group by pot.production_order_id, ppi.product_model_id
    </select>
    <select id="listConsumedMaterials" resultType="com.ruoyi.cost.bean.vo.CostMissingPriceVo">
        select ppi.product_model_id                as productModelId,
               ifnull(pmat.product_name, '')       as materialName,
               ifnull(pmm.model, '')               as materialModel,
               pmm.unit                            as unit,
               sum(ifnull(ppi.input_quantity, 0))  as inputQty,
               count(distinct pot.production_order_id) as orderCount,
               max(ppi.create_time)                as lastInputTime
        from production_product_input ppi
                 join production_product_main ppm on ppm.id = ppi.production_product_main_id
                 join production_operation_task pot on pot.id = ppm.production_operation_task_id
                 join production_order po on po.id = pot.production_order_id
                 left join product_model pm on pm.id = po.product_model_id
                 left join product p on p.id = pm.product_id
                 left join product_model pmm on pmm.id = ppi.product_model_id
                 left join product pmat on pmat.id = pmm.product_id
        <where>
            and ppi.product_model_id is not null
            <include refid="orderFilter"/>
        </where>
        group by ppi.product_model_id, pmat.product_name, pmm.model, pmm.unit
        order by inputQty desc
    </select>
    <!-- ç‰©æ–™å•价来源,priority è¶Šå°è¶Šä¼˜å…ˆï¼šä»·ç›®è¡¨ &gt; é‡‡è´­æ˜Žç»† &gt; é‡‡è´­å…¥åº“记录 &gt; å‘票登记 -->
    <select id="listMaterialPrices" resultType="com.ruoyi.cost.bean.vo.CostMaterialPriceVo">
        select pm.id                      as productModelId,
               ppm_price.base_price       as price,
               '采购价目表'                as priceSource,
               1                          as priority
        from procurement_price_management ppm_price
                 join product_model pm on pm.product_code = ppm_price.product_code
                 join (select product_code, max(id) as mid
                       from procurement_price_management
                       group by product_code) latest on latest.mid = ppm_price.id
        where ppm_price.product_code is not null and ppm_price.product_code &lt;&gt; ''
          and ifnull(ppm_price.base_price, 0) &gt; 0
        union all
        select slp.product_model_id,
               slp.tax_inclusive_unit_price,
               '采购明细',
               2
        from sales_ledger_product slp
                 join (select product_model_id, max(id) as mid
                       from sales_ledger_product
                       where type = 2 and product_model_id is not null
                       group by product_model_id) latest on latest.mid = slp.id
        where ifnull(slp.tax_inclusive_unit_price, 0) &gt; 0
        union all
        select prs.product_model_id,
               prs.unit_price,
               '采购入库记录',
               3
        from procurement_record_storage prs
                 join (select product_model_id, max(id) as mid
                       from procurement_record_storage
                       where product_model_id is not null
                       group by product_model_id) latest on latest.mid = prs.id
        where ifnull(prs.unit_price, 0) &gt; 0
        union all
        select rec.product_model_id,
               rec.tax_inclusive_unit_price,
               '采购发票登记',
               4
        from product_record rec
                 join (select product_model_id, max(id) as mid
                       from product_record
                       where product_model_id is not null
                       group by product_model_id) latest on latest.mid = rec.id
        where ifnull(rec.tax_inclusive_unit_price, 0) &gt; 0
        order by productModelId, priority
    </select>
    <select id="listLaborCosts" resultType="com.ruoyi.cost.bean.vo.CostLaborVo">
        select pot.production_order_id as productionOrderId,
               count(*)                as accountRows,
               sum(cast(
                       case
                           when prc.unit_price is not null
                               then ifnull(pa.finished_num, 0) * prc.unit_price
                           when poro.type = 0
                               then ifnull(pa.work_hours, 0) * ifnull(ppm.work_hour, 0)
                           else ifnull(pa.work_hours, 0) * ifnull(pa.finished_num, 0)
                                * ifnull(cast(nullif(substring_index(pm.model, '*', -1), pm.model) as decimal(18, 4)), 0)
                       end as decimal(18, 2))) as laborCost,
               sum(case when prc.unit_price is null and ifnull(pa.work_hours, 0) = 0 then 1 else 0 end) as noRateRows
        from production_account pa
                 join production_product_main ppm on ppm.id = pa.production_product_main_id
                 join production_operation_task pot on pot.id = ppm.production_operation_task_id
                 join production_order po on po.id = pot.production_order_id
                 left join production_order_routing_operation poro on poro.id = pot.production_order_routing_operation_id
                 left join product_model pm on pm.id = po.product_model_id
                 left join product p on p.id = pm.product_id
                 left join piece_rate_config prc
                           on prc.product_model_id = pm.id
                               and prc.operation_name = pa.technology_operation_name
                               and prc.status = 1
        <where>
            <include refid="orderFilter"/>
        </where>
        group by pot.production_order_id
    </select>
    <select id="listSalePrices" resultType="com.ruoyi.cost.bean.vo.CostSalePriceVo">
        select po.id                            as productionOrderId,
               slp.tax_inclusive_unit_price     as salePrice
        from production_order po
                 join production_plan pp
                      on find_in_set(pp.id, replace(replace(replace(po.production_plan_ids, '[', ''), ']', ''), ' ', '')) &gt; 0
                 join sales_ledger_product slp
                      on slp.sales_ledger_id = pp.sales_ledger_id
                          and slp.product_model_id = po.product_model_id
                          and slp.type = 1
                 left join product_model pm on pm.id = po.product_model_id
                 left join product p on p.id = pm.product_id
        <where>
            <include refid="orderFilter"/>
        </where>
        order by po.id, slp.id desc
    </select>
    <select id="sumEnergyCost" resultType="com.ruoyi.cost.bean.vo.CostEnergyVo">
        select ifnull(sum(ifnull(eec.sum_num, 0)), 0)                            as kwh,
               ifnull(sum(ifnull(eec.sum_num, 0) * ifnull(ep.price, 0)), 0)      as energyCost
        from equipment_energy_consumption eec
                 left join energy_period ep on ep.date = eec.run_date
        <where>
            <if test="startDate != null">and eec.run_date &gt;= #{startDate}</if>
            <if test="endDate != null">and eec.run_date &lt;= #{endDate}</if>
        </where>
    </select>
</mapper>
src/main/resources/mapper/production/MaterialConsumptionMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.production.mapper.MaterialConsumptionMapper">
    <select id="listOrders" resultType="com.ruoyi.production.bean.vo.MaterialConsumptionOrderVo">
        select po.id                                          as productionOrderId,
               po.nps_no                                      as npsNo,
               po.product_model_id                            as productModelId,
               concat(ifnull(p.product_name, ''), '/', ifnull(pm.model, '')) as finishedProduct,
               ifnull(po.complete_quantity, 0)                as completeQty,
               ifnull(po.quantity, 0)                         as planQty
        from production_order po
                 left join product_model pm on pm.id = po.product_model_id
                 left join product p on p.id = pm.product_id
        <where>
            <!-- è®¢å•日期取 create_time,历史数据未回填时回退 start_time,避免筛选丢单 -->
            <if test="startDate != null">and date(coalesce(po.create_time, po.start_time)) &gt;= #{startDate}</if>
            <if test="endDate != null">and date(coalesce(po.create_time, po.start_time)) &lt;= #{endDate}</if>
            <if test="npsNo != null and npsNo != ''">and po.nps_no like concat('%', #{npsNo}, '%')</if>
            <if test="productName != null and productName != ''">
                and (p.product_name like concat('%', #{productName}, '%')
                    or pm.model like concat('%', #{productName}, '%'))
            </if>
        </where>
        order by po.id desc
    </select>
    <select id="listStructures" resultType="com.ruoyi.production.bean.vo.MaterialConsumptionStructureVo">
        select pbs.production_order_id as productionOrderId,
               pbs.id                  as id,
               pbs.parent_id           as parentId,
               pbs.product_model_id    as productModelId,
               ifnull(pbs.unit_quantity, 0) as unitQuantity
        from production_bom_structure pbs
        where pbs.production_order_id in
        <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
        order by pbs.id
    </select>
    <select id="listActualInputs" resultType="com.ruoyi.production.bean.vo.MaterialConsumptionActualVo">
        select pot.production_order_id          as productionOrderId,
               ppi.product_model_id             as productModelId,
               sum(ifnull(ppi.input_quantity, 0)) as actualQty
        from production_product_input ppi
                 join production_product_main ppm on ppm.id = ppi.production_product_main_id
                 join production_operation_task pot on pot.id = ppm.production_operation_task_id
        where pot.production_order_id in
        <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
        group by pot.production_order_id, ppi.product_model_id
    </select>
</mapper>
src/main/resources/mapper/production/ProductionProductMainMapper.xml
@@ -81,8 +81,99 @@
            <if test="c.productMainId != null">
                and ppm.id = #{c.productMainId}
            </if>
            <if test="c.deviceId != null">
                and ppm.device_id = #{c.deviceId}
            </if>
            <if test="c.deviceName != null and c.deviceName != ''">
                and ppm.device_name like concat('%', #{c.deviceName}, '%')
            </if>
        </where>
        order by pot.work_order_no desc, poro.drag_sort, ppm.product_no desc
    </select>
    <select id="wageSummary" resultType="com.ruoyi.production.bean.vo.PieceWageSummaryVo">
        select pa.scheduling_user_id                                                              as userId,
               pa.scheduling_user_name                                                            as userName,
               count(distinct pa.production_product_main_id)                                      as workCount,
               sum(ifnull(pa.finished_num, 0))                                                    as quantity,
               sum(ifnull(ppm.work_hour, 0))                                                      as workHour,
               sum(cast(ifnull(prc.unit_price, 0) * ifnull(pa.finished_num, 0) as decimal(18, 2))) as pieceWage,
               sum(
                   case
                       when prc.unit_price is not null
                           then cast(ifnull(pa.finished_num, 0) * prc.unit_price as decimal(18, 2))
                       when poro.type = 0
                           then cast(ifnull(pa.work_hours, 0) * ifnull(ppm.work_hour, 0) as decimal(18, 2))
                       else cast(ifnull(pa.work_hours, 0) * ifnull(pa.finished_num, 0) *
                                case
                                    when substring_index(pm.model, '*', -1) regexp '^[0-9]+(\\.[0-9]+)?$'
                                        then cast(substring_index(pm.model, '*', -1) as decimal(18, 4))
                                    else 1
                                end as decimal(18, 2))
                   end
               )                                                                                  as wages
        from production_account pa
                 join production_product_main ppm on ppm.id = pa.production_product_main_id
                 left join production_operation_task pot on ppm.production_operation_task_id = pot.id
                 left join production_order po on pot.production_order_id = po.id
                 left join production_order_routing_operation poro on pot.production_order_routing_operation_id = poro.id
                 left join product_model pm on pm.id = ifnull(poro.product_model_id, po.product_model_id)
                 left join piece_rate_config prc on prc.product_model_id = pm.id
                     and prc.operation_name = pa.technology_operation_name
                     and prc.status = 1
        <where>
            <if test="startDate != null">and date(pa.scheduling_date) &gt;= #{startDate}</if>
            <if test="endDate != null">and date(pa.scheduling_date) &lt;= #{endDate}</if>
            <if test="userName != null and userName != ''">and pa.scheduling_user_name like concat('%', #{userName}, '%')</if>
        </where>
        group by pa.scheduling_user_id, pa.scheduling_user_name
        order by wages desc
    </select>
    <!-- å·¥æ—¶/报废是报工单级标量,一单多工序时直接 sum ä¼šè¢«è¡Œæ•°æ”¾å¤§ï¼Œæ•…用 rn=1 åªåœ¨å…¶é¦–道工序行计入 -->
    <select id="outputStat" resultType="com.ruoyi.production.bean.vo.DeviceOutputStatVo">
        select
        t.groupName,
        <if test="dimension == 'device'">t.groupId,</if>
        count(distinct t.ppmId) as workCount,
        sum(ifnull(t.finishedNum, 0)) as quantity,
        sum(case when t.rn = 1 then ifnull(t.scrapQty, 0) else 0 end) as scrapQty,
        sum(case when t.rn = 1 then ifnull(t.workHour, 0) else 0 end) as workHour
        from (
            select
            <choose>
                <when test="dimension == 'operation'">pa.technology_operation_name</when>
                <when test="dimension == 'product'">concat(ifnull(p.product_name, ''), '/', ifnull(pm.model, ''))</when>
                <otherwise>ifnull(ppm.device_name, '未指定设备')</otherwise>
            </choose>
            as groupName,
            ppm.device_id as groupId,
            ppm.id as ppmId,
            pa.finished_num as finishedNum,
            ppm.work_hour as workHour,
            ppo.scrap_qty as scrapQty,
            row_number() over (partition by ppm.id order by pa.id) as rn
            from production_account pa
            join production_product_main ppm on ppm.id = pa.production_product_main_id
            left join production_operation_task pot on ppm.production_operation_task_id = pot.id
            left join production_order po on pot.production_order_id = po.id
            left join production_order_routing_operation poro on pot.production_order_routing_operation_id = poro.id
            left join product_model pm on pm.id = ifnull(poro.product_model_id, po.product_model_id)
            left join product p on pm.product_id = p.id
            left join (
                select production_product_main_id, sum(ifnull(scrap_qty, 0)) as scrap_qty
                from production_product_output
                group by production_product_main_id
            ) ppo on ppo.production_product_main_id = ppm.id
            <where>
                <if test="startDate != null">and date(pa.scheduling_date) &gt;= #{startDate}</if>
                <if test="endDate != null">and date(pa.scheduling_date) &lt;= #{endDate}</if>
                <if test="deviceId != null">and ppm.device_id = #{deviceId}</if>
            </where>
        ) t
        group by t.groupName
        <if test="dimension == 'device'">, t.groupId</if>
        order by quantity desc
    </select>
    <select id="getOrderByMainId" resultType="com.ruoyi.production.pojo.ProductionOrder">
@@ -104,6 +195,7 @@
               cast(ifnull(pa.work_hours, 0) as decimal(18,4)) as workHours,
               cast(
                   case
                       when prc.unit_price is not null then ifnull(pa.finished_num, 0) * prc.unit_price
                       when poro.type = 0 then ifnull(pa.work_hours, 0) * ifnull(ppm.work_hour, 0)
                       else ifnull(pa.work_hours, 0) * ifnull(pa.finished_num, 0) *
                            case
@@ -113,7 +205,10 @@
                            end
                   end
                   as decimal(18,4)
               ) as wages
               ) as wages,
               case when prc.unit_price is not null then '计件' else '计时' end as wageMode,
               ppm.device_id as deviceId,
               ppm.device_name as deviceName
        from production_account pa
                 left join production_product_main ppm on ppm.id = pa.production_product_main_id
                 left join production_operation_task pot on ppm.production_operation_task_id = pot.id
@@ -123,6 +218,9 @@
                 left join product p on pm.product_id = p.id
                 left join product p_parent on p_parent.id = p.parent_id
                 left join production_product_output ppo on ppo.production_product_main_id = ppm.id
                 left join piece_rate_config prc on prc.product_model_id = pm.id
                     and prc.operation_name = pa.technology_operation_name
                     and prc.status = 1
        <where>
            <if test="c != null">
                <if test="c.productCategory != null and c.productCategory != ''">
src/main/resources/mapper/quality/QualityTraceMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,313 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.quality.mapper.QualityTraceMapper">
    <!-- æŠ¥å·¥èŠ‚ç‚¹å…¬å…±åˆ—ï¼šæŠ¥å·¥å•â†’å·¥å•â†’ç”Ÿäº§è®¢å•â†’æˆå“ -->
    <sql id="reportColumns">
        ppm.id                                                as productMainId,
        pot.production_order_id                               as productionOrderId,
        po.nps_no                                             as npsNo,
        concat(ifnull(p.product_name, ''), '/', ifnull(pmf.model, '')) as finishedProduct,
        pmf.unit                                              as unit,
        ifnull(po.quantity, 0)                                as planQty,
        ifnull(po.complete_quantity, 0)                       as completeQty,
        po.status                                             as orderStatus,
        coalesce(po.create_time, po.start_time)               as orderCreateTime,
        pot.work_order_no                                     as workOrderNo,
        poro.operation_name                                   as operationName,
        ppm.user_name                                         as userName,
        ppm.device_name                                       as deviceName,
        ifnull(ppo.quantity, 0)                               as outputQty,
        ifnull(ppo.scrap_qty, 0)                              as scrapQty,
        ppm.product_no                                        as productNo,
        ppm.create_time                                       as reportTime
    </sql>
    <sql id="reportJoins">
        from production_product_main ppm
        join production_operation_task pot on pot.id = ppm.production_operation_task_id
        join production_order po on po.id = pot.production_order_id
        left join production_order_routing_operation poro on poro.id = pot.production_order_routing_operation_id
        left join product_model pmf on pmf.id = po.product_model_id
        left join product p on p.id = pmf.product_id
        left join production_product_output ppo on ppo.production_product_main_id = ppm.id
    </sql>
    <select id="listInboundByFinishedBatch" resultType="com.ruoyi.quality.dto.QualityTraceFinishedBatchVo">
        select x.productionOrderId, x.productMainId, x.batchNo, x.productModelId, x.qty, x.inTime, x.recordType
        from (
            select pot.production_order_id as productionOrderId,
                   sir.record_id           as productMainId,
                   sir.batch_no            as batchNo,
                   sir.product_model_id    as productModelId,
                   ifnull(sir.stock_in_num, 0) as qty,
                   sir.create_time         as inTime,
                   sir.record_type         as recordType
            from stock_in_record sir
                     join production_product_main ppm on ppm.id = sir.record_id
                     join production_operation_task pot on pot.id = ppm.production_operation_task_id
            where sir.batch_no = #{batchNo} and sir.record_type = '2'
            union all
            select pot.production_order_id,
                   qi.product_main_id,
                   sir.batch_no,
                   sir.product_model_id,
                   ifnull(sir.stock_in_num, 0),
                   sir.create_time,
                   sir.record_type
            from stock_in_record sir
                     join quality_inspect qi on qi.id = sir.record_id
                     join production_product_main ppm on ppm.id = qi.product_main_id
                     join production_operation_task pot on pot.id = ppm.production_operation_task_id
            where sir.batch_no = #{batchNo} and sir.record_type = '6'
        ) x
        order by x.productionOrderId, x.inTime
    </select>
    <select id="listReportsByMainIds" resultType="com.ruoyi.quality.dto.QualityTraceReportVo">
        select <include refid="reportColumns"/>
        <include refid="reportJoins"/>
        where ppm.id in
        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
        order by pot.production_order_id, poro.drag_sort, ppm.id
    </select>
    <select id="listReportsByOrderIds" resultType="com.ruoyi.quality.dto.QualityTraceReportVo">
        select <include refid="reportColumns"/>
        <include refid="reportJoins"/>
        where pot.production_order_id in
        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
        order by pot.production_order_id, poro.drag_sort, ppm.id
    </select>
    <select id="listInputsByMainIds" resultType="com.ruoyi.quality.dto.QualityTraceInputVo">
        select ppi.production_product_main_id as productMainId,
               ppi.product_model_id           as productModelId,
               ifnull(p.product_name, '')     as materialName,
               ifnull(pm.model, '')           as materialModel,
               pm.unit                        as unit,
               ifnull(ppi.input_quantity, 0)  as inputQty,
               pot.work_order_no              as workOrderNo,
               poro.operation_name            as operationName
        from production_product_input ppi
                 join production_product_main ppm on ppm.id = ppi.production_product_main_id
                 join production_operation_task pot on pot.id = ppm.production_operation_task_id
                 left join production_order_routing_operation poro on poro.id = pot.production_order_routing_operation_id
                 join product_model pm on pm.id = ppi.product_model_id
                 left join product p on p.id = pm.product_id
        where ppi.production_product_main_id in
        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
        order by ppi.production_product_main_id, ppi.id
    </select>
    <sql id="pickColumns">
        pop.production_order_id  as productionOrderId,
        pop.product_model_id     as productModelId,
        ifnull(p.product_name, '') as materialName,
        ifnull(pm.model, '')     as materialModel,
        pm.unit                  as unit,
        pop.batch_no             as batchNo,
        ifnull(pop.quantity, 0)  as quantity,
        pop.operation_name       as operationName,
        pop.create_time          as pickTime
    </sql>
    <select id="listPicksByOrderIds" resultType="com.ruoyi.quality.dto.QualityTracePickVo">
        select <include refid="pickColumns"/>
        from production_order_pick pop
                 join product_model pm on pm.id = pop.product_model_id
                 left join product p on p.id = pm.product_id
        where pop.production_order_id in
        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
          and pop.batch_no is not null and pop.batch_no &lt;&gt; ''
        order by pop.production_order_id, pop.id
    </select>
    <select id="listPicksByBatchNo" resultType="com.ruoyi.quality.dto.QualityTracePickVo">
        select <include refid="pickColumns"/>
        from production_order_pick pop
                 join product_model pm on pm.id = pop.product_model_id
                 left join product p on p.id = pm.product_id
        where pop.batch_no = #{batchNo}
        order by pop.production_order_id, pop.id
    </select>
    <select id="listInspectsByMainIds" resultType="com.ruoyi.quality.dto.QualityTraceInspectVo">
        select qi.id                             as inspectId,
               qi.product_main_id                as productMainId,
               qi.inspect_type                   as inspectType,
               case qi.inspect_type when 0 then '原料检验' when 1 then '过程检验' when 2 then '出厂检验'
                                    else '其他' end as inspectTypeName,
               qi.process                        as process,
               qi.check_name                     as checkName,
               qi.product_name                   as productName,
               qi.model                          as model,
               qi.unit                           as unit,
               ifnull(qi.quantity, 0)            as quantity,
               ifnull(qi.qualified_quantity, 0)  as qualifiedQuantity,
               ifnull(qi.unqualified_quantity, 0) as unqualifiedQuantity,
               qi.check_result                   as checkResult,
               qi.check_time                     as checkTime,
               qi.inspect_state                  as inspectState,
               qi.defective_phenomena            as defectivePhenomena
        from quality_inspect qi
        where qi.product_main_id in
        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
        order by qi.id
    </select>
    <select id="listUnqualifiedByInspectIds" resultType="com.ruoyi.quality.dto.QualityTraceUnqualifiedVo">
        select qu.id                             as id,
               qu.inspect_id                     as inspectId,
               qu.product_name                   as productName,
               qu.model                          as model,
               qu.unit                           as unit,
               ifnull(qu.quantity, 0)            as quantity,
               qu.defective_phenomena            as defectivePhenomena,
               qu.deal_result                    as dealResult,
               qu.deal_name                      as dealName,
               qu.deal_time                      as dealTime,
               qu.inspect_state                  as inspectState,
               qu.check_time                     as checkTime
        from quality_unqualified qu
        where qu.inspect_id in
        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
        order by qu.id
    </select>
    <select id="listOutboundByBatch" resultType="com.ruoyi.quality.dto.QualityTraceOutboundVo">
        select sor.id                            as id,
               sor.batch_no                      as batchNo,
               ifnull(p.product_name, '')        as productName,
               ifnull(pm.model, '')              as productModel,
               pm.unit                           as unit,
               ifnull(sor.stock_out_num, 0)      as qty,
               sor.record_type                   as recordType,
               sor.create_time                   as outTime,
               sor.remark                        as remark
        from stock_out_record sor
                 join product_model pm on pm.id = sor.product_model_id
                 left join product p on p.id = pm.product_id
        where sor.batch_no = #{batchNo}
        order by sor.id
    </select>
    <select id="listFinishedBatchesByOrderIds" resultType="com.ruoyi.quality.dto.QualityTraceFinishedBatchVo">
        select x.productionOrderId, x.productMainId, x.batchNo, x.productModelId, x.qty, x.inTime, x.recordType
        from (
            select pot.production_order_id as productionOrderId,
                   sir.record_id           as productMainId,
                   sir.batch_no            as batchNo,
                   sir.product_model_id    as productModelId,
                   ifnull(sir.stock_in_num, 0) as qty,
                   sir.create_time         as inTime,
                   sir.record_type         as recordType
            from stock_in_record sir
                     join production_product_main ppm on ppm.id = sir.record_id
                     join production_operation_task pot on pot.id = ppm.production_operation_task_id
                     join production_order po on po.id = pot.production_order_id
            where sir.record_type = '2' and sir.batch_no is not null and sir.batch_no &lt;&gt; ''
              and sir.product_model_id = po.product_model_id
              and pot.production_order_id in
              <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
            union all
            select pot.production_order_id,
                   qi.product_main_id,
                   sir.batch_no,
                   sir.product_model_id,
                   ifnull(sir.stock_in_num, 0),
                   sir.create_time,
                   sir.record_type
            from stock_in_record sir
                     join quality_inspect qi on qi.id = sir.record_id
                     join production_product_main ppm on ppm.id = qi.product_main_id
                     join production_operation_task pot on pot.id = ppm.production_operation_task_id
                     join production_order po on po.id = pot.production_order_id
            where sir.record_type = '6' and sir.batch_no is not null and sir.batch_no &lt;&gt; ''
              and sir.product_model_id = po.product_model_id
              and pot.production_order_id in
              <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
        ) x
        order by x.productionOrderId, x.inTime
    </select>
    <select id="listMaterialBatchInfoByBatches" resultType="com.ruoyi.quality.dto.QualityTraceMaterialBatchVo">
        select sir.id                                as inRecordId,
               sir.batch_no                          as batchNo,
               sir.product_model_id                  as productModelId,
               ifnull(p.product_name, '')            as materialName,
               ifnull(pm.model, '')                  as materialModel,
               pm.unit                               as unit,
               ifnull(sir.stock_in_num, 0)           as qty,
               sir.create_time                       as inTime,
               sir.record_type                       as recordType,
               qi.id                                 as inspectId,
               ifnull(qi.quantity, 0)                as inspectQty,
               ifnull(qi.qualified_quantity, 0)      as qualifiedQty,
               ifnull(qi.unqualified_quantity, 0)    as unqualifiedQty,
               qi.check_result                       as checkResult,
               qi.check_time                         as checkTime,
               coalesce(pl.supplier_name, pl_direct.supplier_name, qi.supplier) as supplierName,
               coalesce(pl.purchase_contract_number, pl_direct.purchase_contract_number) as contractNo,
               coalesce(pl.entry_date, pl_direct.entry_date) as entryDate
        from stock_in_record sir
                 join product_model pm on pm.id = sir.product_model_id
                 left join product p on p.id = pm.product_id
                 left join quality_inspect qi on qi.id = sir.record_id and sir.record_type in ('6', '10')
                 left join purchase_ledger pl on pl.id = qi.purchase_ledger_id
                 left join purchase_ledger pl_direct on pl_direct.id = sir.record_id and sir.record_type = '7'
        where sir.batch_no in
        <foreach collection="batchNos" item="bn" open="(" separator="," close=")">#{bn}</foreach>
        order by sir.id
    </select>
    <select id="listBatchOptions" resultType="com.ruoyi.quality.dto.QualityTraceBatchOptionVo">
        <choose>
            <when test="type == 'material'">
                select pop.batch_no                       as batchNo,
                       ifnull(p.product_name, '')         as productName,
                       ifnull(pm.model, '')               as productModel,
                       pm.unit                            as unit,
                       ifnull(pop.quantity, 0)            as qty,
                       pop.create_time                    as inTime,
                       po.nps_no                          as npsNo
                from production_order_pick pop
                         join product_model pm on pm.id = pop.product_model_id
                         left join product p on p.id = pm.product_id
                         left join production_order po on po.id = pop.production_order_id
                where pop.batch_no is not null and pop.batch_no &lt;&gt; ''
            </when>
            <otherwise>
                select sir.batch_no                       as batchNo,
                       ifnull(p.product_name, '')         as productName,
                       ifnull(pm.model, '')               as productModel,
                       pm.unit                            as unit,
                       ifnull(sir.stock_in_num, 0)        as qty,
                       sir.create_time                    as inTime,
                       po.nps_no                          as npsNo
                from stock_in_record sir
                         join product_model pm on pm.id = sir.product_model_id
                         left join product p on p.id = pm.product_id
                         left join quality_inspect qi on qi.id = sir.record_id and sir.record_type = '6'
                         left join production_product_main ppm
                                   on ppm.id = case when sir.record_type = '2' then sir.record_id else qi.product_main_id end
                         left join production_operation_task pot on pot.id = ppm.production_operation_task_id
                         left join production_order po on po.id = pot.production_order_id
                where sir.record_type in ('2', '6') and sir.batch_no is not null and sir.batch_no &lt;&gt; ''
            </otherwise>
        </choose>
        <if test="keyword != null and keyword != ''">
            and (
            <choose>
                <when test="type == 'material'">pop</when>
                <otherwise>sir</otherwise>
            </choose>
            .batch_no like concat('%', #{keyword}, '%')
                or p.product_name like concat('%', #{keyword}, '%')
                or pm.model like concat('%', #{keyword}, '%')
            )
        </if>
        order by inTime desc
        limit #{limit}
    </select>
</mapper>
src/main/resources/mapper/stock/StockInventoryMapper.xml
@@ -84,6 +84,10 @@
        <if test="ew.productName != null and ew.productName !=''">
            and p.product_name like concat('%',#{ew.productName},'%')
        </if>
        <if test="ew.warnOnly != null and ew.warnOnly">
            and COALESCE(si.warn_num, 0) &gt; 0
            and (si.qualitity - COALESCE(si.locked_quantity, 0)) &lt;= si.warn_num
        </if>
    </select>
    <select id="pageListCombinedStockInventory" resultType="com.ruoyi.stock.dto.StockInventoryDto">