zss
2 天以前 9d42f647f5589e4a560d745d6b359ae6c273bd8d
Merge branch 'dev_New_pro' into dev_宁夏_英泽防锈
已添加20个文件
已重命名9个文件
已修改98个文件
已删除6个文件
5136 ■■■■■ 文件已修改
doc/20260507_采购多文件分析确认接口传参类型约束.md 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260508_采购多文件分析附件存储与历史回显联调说明.md 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/AccountDto.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/AccountDto2.java 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/AccountDto3.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/AccountSubjectDto.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/AccountSubjectImportDto.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/DeviceTypeDetail.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/DeviceTypeDistributionVO.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/ReportDateDto.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/SalesOutboundDto.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/SalesReceiptReturnDto.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/SalesRefundAmountOrderDto.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/dto/SalesReturnDto.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/AccountSubjectVo.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/SalesOutboundVo.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/bean/vo/SalesReturnVo.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/AccountExpenseController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/AccountSalesController.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/AccountSubjectController.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/SalesReceiptReturnController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/SalesRefundAmountOrderController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/AccountExpenseMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/AccountIncomeMapper.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/AccountSubjectMapper.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/mapper/SalesRefundAmountOrderMapper.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/AccountSubject.java 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/AccountExpenseService.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/AccountIncomeService.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/AccountSalesService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/AccountSubjectService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/SalesReceiptReturnService.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/SalesRefundAmountOrderService.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/AccountExpenseServiceImpl.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/AccountIncomeServiceImpl.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/AccountSalesServiceImpl.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/AccountSubjectServiceImpl.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/AccountingServiceImpl.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/SalesReceiptReturnServiceImpl.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/service/impl/SalesRefundAmountOrderServiceImpl.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java 855 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/dto/AiChatMessageDto.java 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/mongodbBean/ChatMessages.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/PurchaseAiService.java 1031 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/impl/AiChatSessionServiceImpl.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/store/MongoChatMemoryStore.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/dto/ProductModelExportDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/enums/StockInQualifiedRecordTypeEnum.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/enums/StockOutQualifiedRecordTypeEnum.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/mapper/DeviceLedgerMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/DeviceRepairServiceImpl.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/mapper/ReturnManagementMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/service/impl/ReturnManagementServiceImpl.java 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/procurementrecord/utils/StockUtils.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/dto/ProductionAccountDto.java 30 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/dto/ProductionPlanDto.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/dto/ProductionProductMainDto.java 40 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionAccountVo.java 35 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOperationTaskVo.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderVo.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderWorkOrderDetailVo.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionOrderController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/controller/ProductionPlanController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/ProductionOrder.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/ProductionOrderRoutingOperation.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/pojo/ProductionProductMain.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/ProductionPlanService.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionAccountServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickRecordServiceImpl.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java 414 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingOperationParamServiceImpl.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingOperationServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java 317 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionProductInputServiceImpl.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/ProductionProductOutputServiceImpl.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/production/service/impl/SalesLedgerProductionAccountingServiceImpl.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/dto/PurchaseLedgerDto.java 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/dto/PurchaseReturnOrderHasAllInfoDto.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/mapper/PurchaseReturnOrdersMapper.java 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/PurchaseReturnOrdersService.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseReturnOrdersServiceImpl.java 27 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/vo/PurchaseReturnDetailsVo.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/SalesLedgerProductController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/ShipmentApprovalController.java 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/controller/ShippingInfoController.java 9 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/ShippingApproveDto.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/ShipmentApprovalMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/ShippingInfoMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/ShippingProductDetailMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesLedgerProduct.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/ShipmentApproval.java 355 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/ShipmentApprovalService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/ShippingInfoService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesQuotationServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/ShipmentApprovalServiceImpl.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/staff/controller/StaffOnJobController.java 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/pojo/TechnologyOperation.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/pojo/TechnologyRoutingOperation.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyOperationParamServiceImpl.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyParamServiceImpl.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev.yml 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/AccountExpenseMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/AccountIncomeMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/AccountSubjectMapper.xml 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/account/SalesRefundAmountOrderMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/device/DeviceLedgerMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/procurementrecord/ReturnManagementMapper.xml 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionAccountMapper.xml 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionOrderRoutingOperationMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionPlanMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/production/ProductionProductMainMapper.xml 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/InvoiceRegistrationProductMapper.xml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/ShipmentApprovalMapper.xml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/ShippingInfoMapper.xml 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/ShippingProductDetailMapper.xml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/staff/StaffOnJobMapper.xml 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/technology/TechnologyRoutingOperationMapper.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260507_²É¹º¶àÎļþ·ÖÎöÈ·ÈϽӿڴ«²ÎÀàÐÍÔ¼Êø.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,166 @@
# é‡‡è´­å¤šæ–‡ä»¶åˆ†æžç¡®è®¤æŽ¥å£ä¼ å‚类型约束(`purchase_ledger`)
## 1. é€‚用接口
- `POST /purchase-ai/analyze-files/confirm`
- `businessType = purchase_ledger`
> æœ¬æ–‡ç”¨äºŽçº¦æŸå‰ç«¯æäº¤åˆ°ç¡®è®¤æŽ¥å£çš„ `payload` ç±»åž‹ï¼Œé¿å… `Cannot deserialize ...` è¿™ç±»ååºåˆ—化异常。
## 2. é¡¶å±‚请求体
```json
{
  "businessType": "purchase_ledger",
  "payload": {
    "purchaseLedgers": []
  }
}
```
## 3. payload ç»“æž„
推荐统一使用批量结构(即使只有 1 æ¡ï¼‰ï¼š
```json
{
  "purchaseLedgers": [
    {
      "purchaseContractNumber": "CG-2026-001",
      "supplierId": 10001,
      "entryDate": "2026-05-07",
      "type": 2,
      "approvalStatus": 1,
      "productData": [
        {
          "productCategory": "钢材",
          "specificationModel": "Q235-A",
          "unit": "吨",
          "quantity": 10,
          "taxInclusiveUnitPrice": 1200,
          "taxInclusiveTotalPrice": 12000,
          "taxRate": 13,
          "type": 2
        }
      ]
    }
  ]
}
```
后端也兼容“单条直传”(`payload` ç›´æŽ¥æ˜¯ä¸€æ¡ `PurchaseLedgerDto`),但不建议新前端继续使用。
## 4. Java ç±»åž‹åˆ° JSON ç±»åž‹æ˜ å°„规则
- `Long` / `Integer`:传 **number**(整数),不要传 `"pending"`、`"1级"` è¿™ç±»å­—符串。
- `BigDecimal`:传 **number**(可小数),不要传带逗号、单位的字符串(如 `"12,000元"`)。
- `Date`:传 **string**,格式固定 `yyyy-MM-dd`。
- `Boolean`:传 `true/false`,不要传 `"true"`、`"是"`。
- `List<T>`:传数组 `[]`。
## 5. `PurchaseLedgerDto` å­—段类型约束(确认接口可识别字段)
| å­—段 | Java ç±»åž‹ | JSON ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- | --- |
| entryDateStart | String | string | æŸ¥è¯¢åŒºé—´å¼€å§‹æ—¥æœŸï¼ˆ`yyyy-MM-dd`) |
| entryDateEnd | String | string | æŸ¥è¯¢åŒºé—´ç»“束日期(`yyyy-MM-dd`) |
| id | Long | number(integer) | å°è´¦ID |
| purchaseContractNumber | String | string | **必填**(主业务单号) |
| supplierId | Long | number(integer) | `supplierId` ä¸Ž `supplierName` äºŒé€‰ä¸€å¿…å¡« |
| supplierName | String | string | `supplierId` ä¸Ž `supplierName` äºŒé€‰ä¸€å¿…å¡« |
| recorderId | Long | number(integer) | å½•入人ID |
| recorderName | String | string | å½•入人名称 |
| salesContractNo | String | string | é”€å”®åˆåŒå· |
| salesContractNoId | Long | number(integer) | é”€å”®åˆåŒID |
| projectName | String | string | é¡¹ç›®åç§° |
| entryDate | Date | string(`yyyy-MM-dd`) | å½•入日期;缺省时后端会补当天 |
| executionDate | Date | string(`yyyy-MM-dd`) | ç­¾è®¢æ—¥æœŸ |
| remarks | String | string | å¤‡æ³¨ |
| attachmentMaterials | String | string | é™„件说明/路径 |
| createdAt | Date | string(`yyyy-MM-dd`) | åˆ›å»ºæ—¥æœŸ |
| updatedAt | Date | string(`yyyy-MM-dd`) | æ›´æ–°æ—¥æœŸ |
| salesLedgerId | Long | number(integer) | é”€å”®å°è´¦ID |
| hasChildren | Boolean | boolean | æ˜¯å¦æœ‰å­é¡¹ |
| Type | Integer | number(integer) | åŽ†å²å­—æ®µï¼ˆä¸æŽ¨èæ–°å‰ç«¯ä½¿ç”¨ï¼‰ |
| productData | List<SalesLedgerProduct> | array | äº§å“æ˜Žç»†ï¼Œè§ä¸‹èŠ‚ |
| tempFileIds | List<String> | array[string] | ä¸´æ—¶æ–‡ä»¶ID列表 |
| SalesLedgerFiles | List<CommonFile> | array[object] | åŽ†å²å…¼å®¹å­—æ®µ |
| phoneNumber | String | string | ä¸šåŠ¡å‘˜æ‰‹æœºå· |
| businessPersonId | Long | number(integer) | ä¸šåŠ¡å‘˜ID |
| productId | Long | number(integer) | äº§å“ID |
| productModelId | Long | number(integer) | äº§å“è§„æ ¼ID |
| invoiceNumber | String | string | å‘票号 |
| invoiceAmount | BigDecimal | number | å‘票金额 |
| ticketRegistrationId | Long | number(integer) | æ¥ç¥¨ç™»è®°ID |
| contractAmount | BigDecimal | number | åˆåŒé‡‘额 |
| receiptPaymentAmount | BigDecimal | number | æ¥ç¥¨é‡‘额 |
| unReceiptPaymentAmount | BigDecimal | number | æœªæ¥ç¥¨é‡‘额 |
| type | Integer | number(integer) | å°è´¦ç±»åž‹ï¼Œé‡‡è´­å›ºå®š `2`(缺省时后端补 `2`) |
| paymentMethod | String | string | ä»˜æ¬¾æ–¹å¼ |
| approvalStatus | Integer | number(integer) | å®¡æ‰¹çŠ¶æ€ï¼Œ**严禁传字符串**(如 `"pending"`) |
| templateName | String | string | æ¨¡æ¿åç§° |
### å®¡æ‰¹çŠ¶æ€å»ºè®®å€¼ï¼ˆ`approvalStatus`)
建议按数字传值:
- `1`:待审核
- `2`:审核中
- `3`:审核通过
- `4`:审核拒绝/失败
- `5`:模板数据(历史定义)
## 6. `productData`(`SalesLedgerProduct`)建议字段及类型
| å­—段 | Java ç±»åž‹ | JSON ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- | --- |
| productCategory | String | string | **必填**,产品大类/名称 |
| specificationModel | String | string | **必填**,规格型号 |
| unit | String | string | **必填**,单位 |
| quantity | BigDecimal | number | **必填**,数量 |
| taxRate | BigDecimal | number | ç¨ŽçŽ‡ï¼ˆå¦‚ `13`) |
| taxInclusiveUnitPrice | BigDecimal | number | **必填**,含税单价 |
| taxInclusiveTotalPrice | BigDecimal | number | **必填**,含税总价 |
| taxExclusiveTotalPrice | BigDecimal | number | ä¸å«ç¨Žæ€»ä»·ï¼ˆå¯ä¸ä¼ ï¼ŒåŽç«¯å¯æŽ¨å¯¼ï¼‰ |
| invoiceType | String | string | å‘票类型 |
| productId | Long | number(integer) | äº§å“ID |
| productModelId | Long | number(integer) | äº§å“åž‹å·ID |
| isChecked | Boolean | boolean | æ˜¯å¦è´¨æ£€ |
| type | Integer | number(integer) | é‡‡è´­äº§å“å›ºå®š `2`(建议传 `2`) |
## 7. å¿…填与后端默认行为
- å°è´¦ä¸»è¡¨å¿…填:`purchaseContractNumber`,以及 `supplierId` / `supplierName` äºŒé€‰ä¸€ã€‚
- äº§å“æ˜Žç»†è‹¥ä¼ äº† `productData`,则每条产品必填:`productCategory`、`specificationModel`、`unit`、`quantity`、`taxInclusiveUnitPrice`、`taxInclusiveTotalPrice`。
- `entryDate` ä¸ºç©ºæ—¶ï¼ŒåŽç«¯è¡¥å½“天日期。
- `type` ä¸ºç©ºæ—¶ï¼ŒåŽç«¯è¡¥ `2`。
## 8. å‰ç«¯é«˜é¢‘错误示例
错误(会触发反序列化异常):
```json
{
  "approvalStatus": "pending",
  "type": "采购",
  "supplierId": "供应商A"
}
```
正确:
```json
{
  "approvalStatus": 1,
  "type": 2,
  "supplierId": 10001
}
```
## 9. æäº¤å‰è‡ªæ£€æ¸…单
1. æ‰€æœ‰ `Long/Integer/BigDecimal` å­—段都为数字,不是业务词字符串。
2. æ‰€æœ‰æ—¥æœŸå­—段都是 `yyyy-MM-dd`。
3. `approvalStatus` ä»…传数字状态码。
4. `supplierId` ä¸Ž `supplierName` è‡³å°‘有一个有效值。
5. `productData` ä¸­å¿…填列齐全且为正确类型。
doc/20260508_²É¹º¶àÎļþ·ÖÎö¸½¼þ´æ´¢ÓëÀúÊ·»ØÏÔÁªµ÷˵Ã÷.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,149 @@
# é‡‡è´­å¤šæ–‡ä»¶åˆ†æžé™„件存储与历史回显联调说明
> æ›´æ–°æ—¶é—´ï¼š2026-05-08
> é€‚用范围:采购智能体多文件分析 + åŽ†å²ä¼šè¯é™„ä»¶å›žæ˜¾
## 1. å˜æ›´èƒŒæ™¯
后端已补齐以下能力:
1. `POST /purchase-ai/analyze-files` ä¸Šä¼ æ—¶å…ˆå­˜é™„件到服务器(公共访问)。
2. æŒ‰æ–‡ä»¶ç±»åž‹è¿”回可访问路径:
   - å›¾ç‰‡ / PDF:优先预览路径
   - å…¶å®ƒæ–‡ä»¶ï¼šä¼˜å…ˆä¸‹è½½è·¯å¾„
3. ç”¨æˆ·æé—®ä¸Žé™„件路径分开存入 Mongo。
4. åŽ†å²æ¶ˆæ¯æŽ¥å£å¯æŒ‰â€œæ¶ˆæ¯ç»´åº¦â€å›žä¼ é™„ä»¶è·¯å¾„ï¼Œå‰ç«¯å¯ç›´æŽ¥å›žæ˜¾é™„ä»¶å¡ç‰‡ã€‚
---
## 2. æŽ¥å£è¡Œä¸º
### 2.1 å¤šæ–‡ä»¶åˆ†æžæŽ¥å£
```http
POST /purchase-ai/analyze-files
Content-Type: multipart/form-data
```
请求参数:
- `files`: `MultipartFile[]`(必填)
- `message`: `string`(可选)
- `memoryId`: `string`(可选)
后端处理流程:
1. è°ƒç”¨ `StorageBlobService.upload(files, true)` ä¸Šä¼ å¹¶ä¿å­˜é™„件。
2. ç”Ÿæˆé™„件访问路径(预览 / ä¸‹è½½ï¼‰ã€‚
3. å°†â€œæœ¬æ¬¡æé—® + æœ¬æ¬¡é™„件路径列表”写入 Mongo。
4. ç»§ç»­æ‰§è¡ŒåŽŸæœ‰æ–‡ä»¶è§£æžå’Œ AI åˆ†æžæµç¨‹ã€‚
可能错误(流式文本):
- `文件上传失败`
- `会话文件信息保存失败`
### 2.2 åŽ†å²æ¶ˆæ¯æŽ¥å£ï¼ˆå‰ç«¯é‡ç‚¹ï¼‰
```http
GET /purchase-ai/history/messages/{memoryId}
```
消息对象新增可选字段 `filePaths`(仅用户消息可能有值):
```json
{
  "role": "user | assistant | system | tool | unknown",
  "content": "消息文本",
  "filePaths": [
    "/common/preview/xxx?publicKey=...",
    "/common/download/yyy?publicKey=..."
  ]
}
```
说明:
1. `filePaths` å¯èƒ½ç¼ºå¤±æˆ–为空(老会话 / æ™®é€šå¯¹è¯ï¼‰ã€‚
2. å•条用户消息可能包含多个附件路径。
3. è¯¥å­—段已按消息维度对齐,可直接用于历史回显。
---
## 3. å‰ç«¯æ”¹é€ å»ºè®®ï¼ˆæŒ‰å½“前实现落地)
### 3.1 åŽ†å²å“åº”æ¨¡åž‹
建议在历史消息原始结构中保留 `filePaths`:
```ts
type AiHistoryMessage = {
  role: string;
  content: string;
  filePaths?: string[];
};
```
### 3.2 UI æ¶ˆæ¯æ¨¡åž‹æ˜ å°„
若前端页面用的是 `localUploadFiles` æ¸²æŸ“附件卡片(如 `AIChatSidebar` å½“前实现),历史消息需做一次映射:
```ts
type LocalUploadFileItem = {
  previewId: string;
  name: string;
  size: number;
  type: string;
  isImage: boolean;
  previewUrl: string;
  rawFile: null;
};
```
映射规则建议:
1. ä»… `role === 'user'` ä¸” `filePaths?.length > 0` æ—¶ç”Ÿæˆ `localUploadFiles`。
2. `previewId` ç”¨ `${memoryId}-${messageIndex}-${fileIndex}` ç”Ÿæˆç¨³å®šå€¼ã€‚
3. `name` å¯ä»Ž URL è·¯å¾„解析;解析失败用 `file-{n}`。
4. `isImage` å¯æŒ‰æ‰©å±•名判断(`png/jpg/jpeg/gif/webp/bmp/svg`)。
5. å›¾ç‰‡çš„ `previewUrl` ç›´æŽ¥ä½¿ç”¨è·¯å¾„;非图片可置空并走图标展示。
### 3.3 æ¶ˆæ¯æ¸²æŸ“
对于用户消息:
1. æ­£å¸¸æ¸²æŸ“ `content`。
2. è‹¥å­˜åœ¨ `localUploadFiles`(或直接使用 `filePaths`),在消息下方渲染附件列表/卡片。
3. é“¾æŽ¥ç›´æŽ¥ä½¿ç”¨åŽç«¯è¿”回路径,不再拼接或二次改写。
> åŽç«¯å·²å®Œæˆâ€œé¢„览/下载路径”选择,前端只负责展示与打开。
### 3.4 å…¼å®¹è¦æ±‚
- `filePaths` ç¼ºå¤±ï¼šä¸æŠ¥é”™ï¼Œä¸æ¸²æŸ“附件区域。
- è€ä¼šè¯ï¼šç»§ç»­æŒ‰ `role/content` å±•示,不影响历史记录查看。
- å¤šé™„件:保持顺序展示,避免打乱用户上传顺序。
---
## 4. Mongo å­—段说明(后端已实现)
`chat_messages` æ–‡æ¡£æ–°å¢ž/使用字段:
- `analyzeUserQuestions: string[]`
- `analyzeFilePaths: string[]`(兼容旧字段)
- `analyzeFilePathGroups: string[][]`(推荐,按提问分组)
历史消息回传时,后端优先读取 `analyzeFilePathGroups`,并兼容 `analyzeFilePaths`。
---
## 5. è”调验收清单
1. æ–°å»ºä¼šè¯ï¼Œä¸Šä¼  1 å¼ å›¾ç‰‡ + 1 ä¸ª Excel,发送分析请求。
2. è°ƒç”¨ `GET /purchase-ai/history/messages/{memoryId}`,确认用户消息含 `filePaths` ä¸”数量正确。
3. åˆ·æ–°é¡µé¢åŽé‡æ–°è¿›å…¥åŒä¸€ä¼šè¯ï¼Œç¡®è®¤é™„件卡片可回显。
4. åˆ†åˆ«éªŒè¯ï¼š
   - å›¾ç‰‡/PDF å¯é¢„览访问
   - å…¶å®ƒæ–‡ä»¶å¯ä¸‹è½½è®¿é—®
5. éªŒè¯è€ä¼šè¯ï¼ˆæ—  `filePaths`)可正常展示,页面不报错。
src/main/java/com/ruoyi/account/bean/dto/AccountDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/AccountDto.java ÐÞ¸Ä
@@ -1,22 +1,10 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.account.pojo.AccountExpense;
import com.ruoyi.account.pojo.AccountIncome;
import com.ruoyi.dto.DateQueryDto;
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 jakarta.validation.constraints.NotBlank;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
 * è´¢åŠ¡ç®¡ç†--财务报表
src/main/java/com/ruoyi/account/bean/dto/AccountDto2.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/AccountDto2.java ÐÞ¸Ä
@@ -1,12 +1,9 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import com.ruoyi.account.pojo.AccountExpense;
import com.ruoyi.account.pojo.AccountIncome;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
/**
 * è´¢åŠ¡ç®¡ç†--财务报表(类型)
src/main/java/com/ruoyi/account/bean/dto/AccountDto3.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/AccountDto3.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import lombok.Data;
src/main/java/com/ruoyi/account/bean/dto/AccountSubjectDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
package com.ruoyi.account.bean.dto;
import com.ruoyi.account.pojo.AccountSubject;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
public class AccountSubjectDto extends AccountSubject {
}
src/main/java/com/ruoyi/account/bean/dto/AccountSubjectImportDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package com.ruoyi.account.bean.dto;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class AccountSubjectImportDto {
    @Schema(description = "科目编码")
    @Excel(name = "科目编码")
    private String subjectCode;
    @Schema(description = "科目名称")
    @Excel(name = "科目名称")
    private String subjectName;
    @Schema(description = "科目类型")
    @Excel(name = "科目类型")
    private String subjectType;
    @Schema(description = "余额方向")
    @Excel(name = "余额方向")
    private String balanceDirection;
    /**
     * çŠ¶æ€ 0启用 1禁用
     */
    @Schema(description = "状态")
    @Excel(name = "状态",readConverterExp = "0=启用,1=禁用")
    private Integer status;
    @Schema(description = "备注")
    @Excel(name = "备注")
    private String remark;
}
src/main/java/com/ruoyi/account/bean/dto/DeviceTypeDetail.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/DeviceTypeDetail.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import lombok.Data;
src/main/java/com/ruoyi/account/bean/dto/DeviceTypeDistributionVO.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/DeviceTypeDistributionVO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import lombok.Data;
src/main/java/com/ruoyi/account/bean/dto/ReportDateDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/ReportDateDto.java ÐÞ¸Ä
@@ -1,11 +1,10 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.util.Date;
/**
 * @author :yys
src/main/java/com/ruoyi/account/bean/dto/SalesOutboundDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.account.bean.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
@Data
@Schema(name = "SalesOutboundDto", description = "财务管理--销售出库台账(传参)")
public class SalesOutboundDto {
    @Schema(description = "出库单号")
    private String outboundBatches;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "开始日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private Date startDate;
    @Schema(description = "结束日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private Date endDate;
}
src/main/java/com/ruoyi/account/bean/dto/SalesReceiptReturnDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/SalesReceiptReturnDto.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import com.ruoyi.account.pojo.SalesReceiptReturn;
import lombok.Data;
src/main/java/com/ruoyi/account/bean/dto/SalesRefundAmountOrderDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/account/dto/SalesRefundAmountOrderDto.java ÐÞ¸Ä
@@ -1,7 +1,6 @@
package com.ruoyi.account.dto;
package com.ruoyi.account.bean.dto;
import com.ruoyi.account.pojo.SalesRefundAmountOrder;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
src/main/java/com/ruoyi/account/bean/dto/SalesReturnDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.account.bean.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
@Data
@Schema(name = "SalesReturnDto", description = "财务管理--销售退货台账(传参)")
public class SalesReturnDto {
    @Schema(description = "退货单号")
    private String returnNo;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "开始日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private Date startDate;
    @Schema(description = "结束日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private Date endDate;
}
src/main/java/com/ruoyi/account/bean/vo/AccountSubjectVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package com.ruoyi.account.bean.vo;
import com.ruoyi.account.pojo.AccountSubject;
import lombok.Data;
@Data
public class AccountSubjectVo extends AccountSubject {
}
src/main/java/com/ruoyi/account/bean/vo/SalesOutboundVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package com.ruoyi.account.bean.vo;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
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.math.BigDecimal;
import java.util.Date;
@Data
@Schema(name = "SalesOutboundVo", description = "财务管理--销售出库台账(返回)")
@ExcelIgnoreUnannotated
public class SalesOutboundVo {
    @Schema(description = "出库单id")
    private Long id;
    @Schema(description = "出库单号")
    @Excel(name = "出库单号")
    private String outboundBatches;
    @Schema(description = "客户名称")
    @Excel(name = "客户名称")
    private String customerName;
    @Schema(description = "出库日期")
    @Excel(name = "出库日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private Date shippingDate;
    @Schema(description = "产品名称")
    @Excel(name = "产品名称")
    private String productName;
    @Schema(description = "产品规格")
    @Excel(name = "产品规格")
    private String  specificationModel;
    @Schema(description = "出库数量")
    @Excel(name = "出库数量")
    private BigDecimal stockOutNum;
    @Schema(description = "发货编号")
    @Excel(name = "发货编号")
    private String shippingNo;
    @Schema(description = "销售订单号")
    @Excel(name = "销售订单号")
    private String salesContractNo;
}
src/main/java/com/ruoyi/account/bean/vo/SalesReturnVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package com.ruoyi.account.bean.vo;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
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.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Schema(name = "SalesReturnVo", description = "财务管理--销售退货台账(返回)")
@ExcelIgnoreUnannotated
public class SalesReturnVo {
    @Schema(description = "退货单id")
    private Long id;
    @Excel(name = "退货单号")
    @Schema(description = "退货单号")
    private String returnNo;
    @Schema(description = "客户名称")
    @Excel(name = "客户名称")
    private String customerName;
    @Schema(description = "关联发货单号")
    @Excel(name = "关联发货单号")
    private String shippingNo;
    @Schema(description = "退货日期")
    @Excel(name = "退货日期")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime makeTime;
    @Schema(description = "退款总额")
    @Excel(name = "退款总额")
    private BigDecimal refundAmount;
    @Schema(description = "退货原因")
    @Excel(name = "退货原因")
    private String returnReason;
    @Schema(description = "销售订单号")
    @Excel(name = "销售订单号")
    private String salesContractNo;
}
src/main/java/com/ruoyi/account/controller/AccountExpenseController.java
@@ -1,7 +1,7 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.ReportDateDto;
import com.ruoyi.account.bean.dto.ReportDateDto;
import com.ruoyi.account.pojo.AccountExpense;
import com.ruoyi.account.service.AccountExpenseService;
import com.ruoyi.account.service.AccountIncomeService;
src/main/java/com/ruoyi/account/controller/AccountSalesController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import com.ruoyi.account.service.AccountSalesService;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
 * <p>
 * è´¢åŠ¡ç®¡ç†çš„é”€å”®éƒ¨åˆ† å‰ç«¯æŽ§åˆ¶å™¨
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
@RestController
@RequestMapping("/accountSales")
@RequiredArgsConstructor
@Tag(name = "财务管理的销售部分")
public class AccountSalesController {
    private final AccountSalesService accountSalesService;
    @GetMapping("/listPageByOutbound")
    @Log(title = "销售出库台账", businessType = BusinessType.OTHER)
    @Operation(summary = "财务管理--销售出库台账")
    public R<IPage<SalesOutboundVo>> listPageByOutbound(Page page, SalesOutboundDto salesOutboundDto) {
        IPage<SalesOutboundVo> listPage = accountSalesService.listPageByOutbound(page,salesOutboundDto);
        return R.ok(listPage);
    }
    @PostMapping("/exportAccountSalesOutbound")
    @Operation(summary = "导出销售出库文件")
    @Log(title = "导出销售出库文件", businessType = BusinessType.EXPORT)
    public void exportAccountSalesOutbound(HttpServletResponse response,SalesOutboundDto salesOutboundDto) {
        accountSalesService.exportAccountSalesOutbound(response,salesOutboundDto);
    }
    @GetMapping("/listPageByReturn")
    @Log(title = "销售退货台账", businessType = BusinessType.OTHER)
    @Operation(summary = "财务管理--销售退货台账")
    public R<IPage<SalesReturnVo>> listPageBySalesReturn(Page page, SalesReturnDto salesReturnDto) {
        IPage<SalesReturnVo> listPage = accountSalesService.listPageBySalesReturn(page,salesReturnDto);
        return R.ok(listPage);
    }
    @PostMapping("/exportAccountSalesReturn")
    @Operation(summary = "导出销售退货文件")
    @Log(title = "导出销售退货文件", businessType = BusinessType.EXPORT)
    public void exportAccountSalesReturn(HttpServletResponse response,SalesReturnDto salesReturnDto) {
        accountSalesService.exportAccountSalesReturn(response,salesReturnDto);
    }
}
src/main/java/com/ruoyi/account/controller/AccountSubjectController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.AccountSubjectDto;
import com.ruoyi.account.bean.vo.AccountSubjectVo;
import com.ruoyi.account.service.AccountSubjectService;
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.web.bind.annotation.*;
import java.util.Arrays;
/**
 * <p>
 * æ€»è´¦ç§‘目表 å‰ç«¯æŽ§åˆ¶å™¨
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
@RestController
@RequestMapping("/accountSubject")
@RequiredArgsConstructor
@Tag(name = "总账科目")
public class AccountSubjectController {
    private final AccountSubjectService accountSubjectService;
    @GetMapping("/list")
    @Log(title = "总账科目数据集合", businessType = BusinessType.OTHER)
    @Operation(summary = "总账科目分页查询")
    public R<IPage<AccountSubjectVo>> AccountSubjectDtoList(Page<AccountSubjectDto> page, AccountSubjectDto accountSubjectDto) {
        IPage<AccountSubjectVo> paramList = accountSubjectService.baseList(page, accountSubjectDto);
        return R.ok(paramList);
    }
    @PostMapping("/add")
    @Log(title = "新增总账科目", businessType = BusinessType.INSERT)
    @Operation(summary = "新增总账科目")
    public R AccountSubjectDtoAdd(@RequestBody AccountSubjectDto accountSubjectDto) {
        return R.ok(accountSubjectService.save(accountSubjectDto));
    }
    @PutMapping("/edit")
    @Log(title = "修改总账科目", businessType = BusinessType.UPDATE)
    @Operation(summary = "修改总账科目")
    public R AccountSubjectDtoEdit(@RequestBody AccountSubjectDto accountSubjectDto) {
        return R.ok(accountSubjectService.updateById(accountSubjectDto));
    }
    @DeleteMapping("/remove/{ids}")
    @Log(title = "删除总账科目", businessType = BusinessType.DELETE)
    @Operation(summary = "删除总账科目")
    public R AccountSubjectDtooRemove(@PathVariable Long[] ids) {
        return R.ok(accountSubjectService.removeBatchByIds(Arrays.asList(ids)));
    }
    @PostMapping("/export")
    @Operation(summary = "导出总账科目文件")
    @Log(title = "导出总账科目文件", businessType = BusinessType.EXPORT)
    public void exportAccountSubject(HttpServletResponse response) {
        accountSubjectService.exportAccountSubject(response);
    }
}
src/main/java/com/ruoyi/account/controller/SalesReceiptReturnController.java
@@ -1,7 +1,7 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.account.dto.SalesReceiptReturnDto;
import com.ruoyi.account.bean.dto.SalesReceiptReturnDto;
import com.ruoyi.account.pojo.SalesReceiptReturn;
import com.ruoyi.account.service.SalesReceiptReturnService;
import com.ruoyi.account.service.impl.SalesReceiptReturnServiceImpl;
src/main/java/com/ruoyi/account/controller/SalesRefundAmountOrderController.java
@@ -1,7 +1,7 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.bean.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.pojo.SalesRefundAmountOrder;
import com.ruoyi.account.service.SalesRefundAmountOrderService;
import com.ruoyi.framework.web.domain.R;
src/main/java/com/ruoyi/account/mapper/AccountExpenseMapper.java
@@ -3,8 +3,8 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.AccountDto;
import com.ruoyi.account.dto.AccountDto2;
import com.ruoyi.account.bean.dto.AccountDto;
import com.ruoyi.account.bean.dto.AccountDto2;
import com.ruoyi.account.pojo.AccountExpense;
import com.ruoyi.account.pojo.AccountIncome;
import com.ruoyi.dto.DateQueryDto;
src/main/java/com/ruoyi/account/mapper/AccountIncomeMapper.java
@@ -3,7 +3,7 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.AccountDto2;
import com.ruoyi.account.bean.dto.AccountDto2;
import com.ruoyi.account.pojo.AccountFile;
import com.ruoyi.account.pojo.AccountIncome;
import com.ruoyi.dto.DateQueryDto;
src/main/java/com/ruoyi/account/mapper/AccountSubjectMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package com.ruoyi.account.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.pojo.AccountSubject;
import org.apache.ibatis.annotations.Mapper;
/**
 * <p>
 * æ€»è´¦ç§‘目表 Mapper æŽ¥å£
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
@Mapper
public interface AccountSubjectMapper extends BaseMapper<AccountSubject> {
}
src/main/java/com/ruoyi/account/mapper/SalesRefundAmountOrderMapper.java
@@ -2,7 +2,7 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.bean.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.pojo.SalesRefundAmountOrder;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
src/main/java/com/ruoyi/account/pojo/AccountSubject.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,111 @@
package com.ruoyi.account.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 io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
 * <p>
 * æ€»è´¦ç§‘目表
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
@Getter
@Setter
@ToString
@TableName("account_subject")
@ApiModel(value = "AccountSubject对象", description = "总账科目表")
public class AccountSubject implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * ä¸»é”®ID
     */
    @ApiModelProperty("主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    /**
     * ç§‘目编码(唯一标识)
     */
    @ApiModelProperty("科目编码(唯一标识)")
    private String subjectCode;
    /**
     * ç§‘目名称
     */
    @ApiModelProperty("科目名称")
    private String subjectName;
    /**
     * ç§‘目类型
     */
    @ApiModelProperty("科目类型")
    private String subjectType;
    /**
     * ä½™é¢æ–¹å‘
     */
    @ApiModelProperty("余额方向")
    private String balanceDirection;
    /**
     * çŠ¶æ€ 0启用 1禁用
     */
    @ApiModelProperty("状态 0启用 1禁用")
    private Integer status;
    /**
     * å¤‡æ³¨
     */
    @ApiModelProperty("备注")
    private String remark;
    /**
     * åˆ›å»ºäºº
     */
    @ApiModelProperty("创建人")
    @TableField(fill = FieldFill.INSERT)
    private String createUser;
    /**
     * åˆ›å»ºæ—¶é—´
     */
    @ApiModelProperty("创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    /**
     * ä¿®æ”¹äºº
     */
    @ApiModelProperty("修改人")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private String updateUser;
    /**
     * ä¿®æ”¹æ—¶é—´
     */
    @ApiModelProperty("修改时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    /**
     * éƒ¨é—¨ID
     */
    @ApiModelProperty("部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/account/service/AccountExpenseService.java
@@ -3,10 +3,10 @@
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.account.dto.AccountDto;
import com.ruoyi.account.dto.AccountDto2;
import com.ruoyi.account.dto.AccountDto3;
import com.ruoyi.account.dto.ReportDateDto;
import com.ruoyi.account.bean.dto.AccountDto;
import com.ruoyi.account.bean.dto.AccountDto2;
import com.ruoyi.account.bean.dto.AccountDto3;
import com.ruoyi.account.bean.dto.ReportDateDto;
import com.ruoyi.account.pojo.AccountExpense;
import com.ruoyi.account.pojo.AccountIncome;
import com.ruoyi.dto.DateQueryDto;
src/main/java/com/ruoyi/account/service/AccountIncomeService.java
@@ -3,9 +3,9 @@
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.account.dto.AccountDto2;
import com.ruoyi.account.dto.AccountDto3;
import com.ruoyi.account.dto.ReportDateDto;
import com.ruoyi.account.bean.dto.AccountDto2;
import com.ruoyi.account.bean.dto.AccountDto3;
import com.ruoyi.account.bean.dto.ReportDateDto;
import com.ruoyi.account.pojo.AccountIncome;
import jakarta.servlet.http.HttpServletResponse;
src/main/java/com/ruoyi/account/service/AccountSalesService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package com.ruoyi.account.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import jakarta.servlet.http.HttpServletResponse;
/**
 * <p>
 * è´¢åŠ¡ç®¡ç†çš„é”€å”®éƒ¨åˆ† æœåŠ¡ç±»
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
public interface AccountSalesService  {
    IPage<SalesOutboundVo> listPageByOutbound(Page page, SalesOutboundDto salesOutboundDto);
    void exportAccountSalesOutbound(HttpServletResponse response, SalesOutboundDto salesOutboundDto);
    IPage<SalesReturnVo> listPageBySalesReturn(Page page, SalesReturnDto salesReturnDto);
    void exportAccountSalesReturn(HttpServletResponse response, SalesReturnDto salesReturnDto);
}
src/main/java/com/ruoyi/account/service/AccountSubjectService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package com.ruoyi.account.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.AccountSubjectDto;
import com.ruoyi.account.bean.vo.AccountSubjectVo;
import com.ruoyi.account.pojo.AccountSubject;
import com.baomidou.mybatisplus.extension.service.IService;
import jakarta.servlet.http.HttpServletResponse;
/**
 * <p>
 * æ€»è´¦ç§‘目表 æœåŠ¡ç±»
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
public interface AccountSubjectService extends IService<AccountSubject> {
    IPage<AccountSubjectVo> baseList(Page<AccountSubjectDto> page, AccountSubjectDto accountSubjectDto);
    void exportAccountSubject(HttpServletResponse response);
}
src/main/java/com/ruoyi/account/service/SalesReceiptReturnService.java
@@ -1,7 +1,7 @@
package com.ruoyi.account.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.account.dto.SalesReceiptReturnDto;
import com.ruoyi.account.bean.dto.SalesReceiptReturnDto;
import com.ruoyi.account.pojo.SalesReceiptReturn;
import com.baomidou.mybatisplus.extension.service.IService;
src/main/java/com/ruoyi/account/service/SalesRefundAmountOrderService.java
@@ -2,7 +2,7 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.bean.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.pojo.SalesRefundAmountOrder;
import com.baomidou.mybatisplus.extension.service.IService;
src/main/java/com/ruoyi/account/service/impl/AccountExpenseServiceImpl.java
@@ -5,10 +5,10 @@
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.account.dto.AccountDto;
import com.ruoyi.account.dto.AccountDto2;
import com.ruoyi.account.dto.AccountDto3;
import com.ruoyi.account.dto.ReportDateDto;
import com.ruoyi.account.bean.dto.AccountDto;
import com.ruoyi.account.bean.dto.AccountDto2;
import com.ruoyi.account.bean.dto.AccountDto3;
import com.ruoyi.account.bean.dto.ReportDateDto;
import com.ruoyi.account.mapper.AccountExpenseMapper;
import com.ruoyi.account.mapper.AccountIncomeMapper;
import com.ruoyi.account.pojo.AccountExpense;
src/main/java/com/ruoyi/account/service/impl/AccountIncomeServiceImpl.java
@@ -5,8 +5,8 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.dto.AccountDto3;
import com.ruoyi.account.dto.ReportDateDto;
import com.ruoyi.account.bean.dto.AccountDto3;
import com.ruoyi.account.bean.dto.ReportDateDto;
import com.ruoyi.account.mapper.AccountIncomeMapper;
import com.ruoyi.account.pojo.AccountIncome;
import com.ruoyi.account.service.AccountIncomeService;
src/main/java/com/ruoyi/account/service/impl/AccountSalesServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package com.ruoyi.account.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import com.ruoyi.account.service.AccountSalesService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.procurementrecord.mapper.ReturnManagementMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
 * <p>
 * è´¢åŠ¡ç®¡ç†çš„é”€å”®éƒ¨åˆ† æœåŠ¡å®žçŽ°ç±»
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
@Service
@RequiredArgsConstructor
public class AccountSalesServiceImpl  implements AccountSalesService {
    private final ShippingInfoMapper shippingInfoMapper;
    private final ReturnManagementMapper returnManagementMapper;
    @Override
    public IPage<SalesOutboundVo> listPageByOutbound(Page page, SalesOutboundDto salesOutboundDto) {
        return shippingInfoMapper.listPageByOutbound(page,salesOutboundDto);
    }
    @Override
    public void exportAccountSalesOutbound(HttpServletResponse response, SalesOutboundDto salesOutboundDto) {
        List<SalesOutboundVo> list = shippingInfoMapper.listPageByOutbound(new Page(1,-1),salesOutboundDto).getRecords();
        ExcelUtil<SalesOutboundVo> util = new ExcelUtil<>(SalesOutboundVo.class);
        util.exportExcel(response, list , "销售出库");
    }
    @Override
    public IPage<SalesReturnVo> listPageBySalesReturn(Page page, SalesReturnDto salesReturnDto) {
        return returnManagementMapper.listPageBySalesReturn(page,salesReturnDto);
    }
    @Override
    public void exportAccountSalesReturn(HttpServletResponse response, SalesReturnDto salesReturnDto) {
        List<SalesReturnVo> list = returnManagementMapper.listPageBySalesReturn(new Page(1,-1),salesReturnDto).getRecords();
        ExcelUtil<SalesReturnVo> util = new ExcelUtil<>(SalesReturnVo.class);
        util.exportExcel(response, list , "销售退货");
    }
}
src/main/java/com/ruoyi/account/service/impl/AccountSubjectServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
package com.ruoyi.account.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.bean.dto.AccountSubjectDto;
import com.ruoyi.account.bean.dto.AccountSubjectImportDto;
import com.ruoyi.account.bean.vo.AccountSubjectVo;
import com.ruoyi.account.mapper.AccountSubjectMapper;
import com.ruoyi.account.pojo.AccountSubject;
import com.ruoyi.account.service.AccountSubjectService;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
 * <p>
 * æ€»è´¦ç§‘目表 æœåŠ¡å®žçŽ°ç±»
 * </p>
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-05-07 04:45:30
 */
@Service
@RequiredArgsConstructor
public class AccountSubjectServiceImpl extends ServiceImpl<AccountSubjectMapper, AccountSubject> implements AccountSubjectService {
    private final AccountSubjectMapper accountSubjectMapper;
    @Override
    public IPage<AccountSubjectVo> baseList(Page<AccountSubjectDto> page, AccountSubjectDto accountSubjectDto) {
        LambdaQueryWrapper<AccountSubject> queryWrapper = new LambdaQueryWrapper<>();
        if (accountSubjectDto != null && StringUtils.isNotEmpty(accountSubjectDto.getSubjectCode())) {
            queryWrapper.like(AccountSubject::getSubjectCode, accountSubjectDto.getSubjectCode());
        }
        if (accountSubjectDto != null && StringUtils.isNotEmpty(accountSubjectDto.getSubjectName())) {
            queryWrapper.like(AccountSubject::getSubjectName, accountSubjectDto.getSubjectName());
        }
        if (accountSubjectDto != null && StringUtils.isNotEmpty(accountSubjectDto.getSubjectType())) {
            queryWrapper.eq(AccountSubject::getSubjectType, accountSubjectDto.getSubjectType());
        }
        queryWrapper.orderByDesc(AccountSubject::getId);
        Page<AccountSubject> entityPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
        Page<AccountSubject> paramPage = page(entityPage, queryWrapper);
        Page<AccountSubjectVo> resultPage = new Page<>(paramPage.getCurrent(), paramPage.getSize(), paramPage.getTotal());
        List<AccountSubjectVo> records = new ArrayList<>(paramPage.getRecords().size());
        for (AccountSubject item : paramPage.getRecords()) {
            AccountSubjectVo vo = new AccountSubjectVo();
            BeanUtils.copyProperties(item, vo);
            records.add(vo);
        }
        resultPage.setRecords(records);
        return resultPage;
    }
    @Override
    public void exportAccountSubject(HttpServletResponse response) {
        List<AccountSubject> list = accountSubjectMapper.selectList(null);
        List<AccountSubjectImportDto> importDtos = list.stream().map(accountSubject -> {
            AccountSubjectImportDto accountSubjectImportDto = new AccountSubjectImportDto();
            BeanUtils.copyProperties(accountSubject, accountSubjectImportDto);
            return accountSubjectImportDto;
        }).collect(Collectors.toList());
        ExcelUtil<AccountSubjectImportDto> util = new ExcelUtil<>(AccountSubjectImportDto.class);
        util.exportExcel(response, importDtos , "总账科目");
    }
}
src/main/java/com/ruoyi/account/service/impl/AccountingServiceImpl.java
@@ -4,8 +4,8 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.DeviceTypeDetail;
import com.ruoyi.account.dto.DeviceTypeDistributionVO;
import com.ruoyi.account.bean.dto.DeviceTypeDetail;
import com.ruoyi.account.bean.dto.DeviceTypeDistributionVO;
import com.ruoyi.account.mapper.BorrowInfoMapper;
import com.ruoyi.account.pojo.BorrowInfo;
import com.ruoyi.device.mapper.DeviceLedgerMapper;
src/main/java/com/ruoyi/account/service/impl/SalesReceiptReturnServiceImpl.java
@@ -1,7 +1,7 @@
package com.ruoyi.account.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.account.dto.SalesReceiptReturnDto;
import com.ruoyi.account.bean.dto.SalesReceiptReturnDto;
import com.ruoyi.account.pojo.SalesReceiptReturn;
import com.ruoyi.account.mapper.SalesReceiptReturnMapper;
import com.ruoyi.account.service.SalesReceiptReturnService;
src/main/java/com/ruoyi/account/service/impl/SalesRefundAmountOrderServiceImpl.java
@@ -3,7 +3,7 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.bean.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.mapper.SalesRefundAmountOrderMapper;
import com.ruoyi.account.pojo.SalesRefundAmountOrder;
import com.ruoyi.account.service.SalesRefundAmountOrderService;
src/main/java/com/ruoyi/ai/controller/PurchaseAiController.java
@@ -1,151 +1,41 @@
package com.ruoyi.ai.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ai.assistant.PurchaseAgent;
import com.ruoyi.ai.assistant.PurchaseIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.bean.PurchaseAiConfirmRequest;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.service.AiChatSessionService;
import com.ruoyi.ai.service.AiFileTextExtractor;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.basic.mapper.SupplierManageMapper;
import com.ruoyi.basic.pojo.SupplierManage;
import com.ruoyi.ai.service.PurchaseAiService;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.purchase.dto.PurchaseLedgerDto;
import com.ruoyi.purchase.dto.PurchaseReturnOrderDto;
import com.ruoyi.purchase.pojo.PaymentRegistration;
import com.ruoyi.purchase.service.IPaymentRegistrationService;
import com.ruoyi.purchase.service.IPurchaseLedgerService;
import com.ruoyi.purchase.service.PurchaseReturnOrdersService;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import dev.langchain4j.data.image.Image;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Base64;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
@Tag(name = "采购智能体")
@RestController
@RequestMapping("/purchase-ai")
public class PurchaseAiController extends BaseController {
    private static final String PURCHASE_FILE_ANALYZE_MEMORY_PREFIX = "purchase-file-analyze::";
    private static final int MAX_FILE_COUNT = 10;
    private static final int MAX_SINGLE_FILE_TEXT_LENGTH = 8000;
    private static final int MAX_TOTAL_FILE_TEXT_LENGTH = 30000;
    private final PurchaseAiService purchaseAiService;
    private final PurchaseAgent purchaseAgent;
    private final PurchaseIntentExecutor purchaseIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    private final AiFileTextExtractor aiFileTextExtractor;
    private final ObjectMapper objectMapper;
    private final IPurchaseLedgerService purchaseLedgerService;
    private final IPaymentRegistrationService paymentRegistrationService;
    private final PurchaseReturnOrdersService purchaseReturnOrdersService;
    private final SupplierManageMapper supplierManageMapper;
    private final StreamingChatLanguageModel purchaseVisionStreamingChatModel;
    public PurchaseAiController(PurchaseAgent purchaseAgent,
                                PurchaseIntentExecutor purchaseIntentExecutor,
                                AiSessionUserContext aiSessionUserContext,
                                MongoChatMemoryStore mongoChatMemoryStore,
                                AiChatSessionService aiChatSessionService,
                                AiFileTextExtractor aiFileTextExtractor,
                                ObjectMapper objectMapper,
                                IPurchaseLedgerService purchaseLedgerService,
                                IPaymentRegistrationService paymentRegistrationService,
                                PurchaseReturnOrdersService purchaseReturnOrdersService,
                                SupplierManageMapper supplierManageMapper,
                                @Qualifier("purchaseVisionStreamingChatModel") StreamingChatLanguageModel purchaseVisionStreamingChatModel) {
        this.purchaseAgent = purchaseAgent;
        this.purchaseIntentExecutor = purchaseIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
        this.aiFileTextExtractor = aiFileTextExtractor;
        this.objectMapper = objectMapper;
        this.purchaseLedgerService = purchaseLedgerService;
        this.paymentRegistrationService = paymentRegistrationService;
        this.purchaseReturnOrdersService = purchaseReturnOrdersService;
        this.supplierManageMapper = supplierManageMapper;
        this.purchaseVisionStreamingChatModel = purchaseVisionStreamingChatModel;
    public PurchaseAiController(PurchaseAiService purchaseAiService) {
        this.purchaseAiService = purchaseAiService;
    }
    @Operation(summary = "采购对话")
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    public Flux<String> chat(@RequestBody ChatForm chatForm) {
        if (!StringUtils.hasText(chatForm.getMemoryId())) {
            return Flux.just("memoryId不能为空");
        }
        if (!StringUtils.hasText(chatForm.getMessage())) {
            return Flux.just("message不能为空");
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        String memoryId = chatForm.getMemoryId();
        String userMessage = chatForm.getMessage();
        aiSessionUserContext.bind(memoryId, loginUser);
        aiChatSessionService.touchSession(memoryId, loginUser, userMessage);
        String directResponse = purchaseIntentExecutor.tryExecute(memoryId, userMessage);
        if (StringUtils.isNotEmpty(directResponse)) {
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(directResponse);
        }
        return purchaseAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
        return purchaseAiService.chat(chatForm, loginUser);
    }
    @Operation(summary = "采购多文件分析")
@@ -153,751 +43,34 @@
    public Flux<String> analyzeFiles(@RequestParam("files") MultipartFile[] files,
                                     @RequestParam(value = "message", required = false) String message,
                                     @RequestParam(value = "memoryId", required = false) String memoryId) {
        if (files == null || files.length == 0) {
            return Flux.just("files不能为空");
        }
        if (files.length > MAX_FILE_COUNT) {
            return Flux.just("一次最多分析" + MAX_FILE_COUNT + "个文件");
        }
        String rawMemoryId = StringUtils.hasText(memoryId) ? memoryId : UUID.randomUUID().toString();
        String finalMemoryId = rawMemoryId.startsWith(PURCHASE_FILE_ANALYZE_MEMORY_PREFIX)
                ? rawMemoryId
                : PURCHASE_FILE_ANALYZE_MEMORY_PREFIX + rawMemoryId;
        LoginUser loginUser = SecurityUtils.getLoginUser();
        aiSessionUserContext.bind(finalMemoryId, loginUser);
        String finalMessage = StringUtils.hasText(message)
                ? message
                : "请分析这些采购文件,提取可用于业务处理的数据,并整理成待客户确认的格式";
        String fileContent;
        try {
            fileContent = buildMultiFileContent(files);
        } catch (IllegalArgumentException ex) {
            return Flux.just(ex.getMessage());
        } catch (IOException ex) {
            return Flux.just("文件读取失败");
        }
        if (!StringUtils.hasText(fileContent)) {
            return Flux.just("未提取到有效文件内容");
        }
        String userPrompt = buildPurchaseFileAnalyzePrompt(finalMessage, fileContent);
        aiChatSessionService.touchSession(finalMemoryId, loginUser, "采购多文件分析: " + finalMessage);
        if (containsImageFile(files)) {
            return chatWithPurchaseVisionModel(finalMemoryId, userPrompt, files)
                    .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                    .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
        }
        return Flux.defer(() -> purchaseAgent.chat(finalMemoryId, userPrompt))
                .onErrorResume(NoSuchElementException.class, ex -> {
                    mongoChatMemoryStore.deleteMessages(finalMemoryId);
                    return purchaseAgent.chat(finalMemoryId, userPrompt);
                })
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
        return purchaseAiService.analyzeFiles(files, message, memoryId, loginUser);
    }
    @Operation(summary = "采购多文件分析确认处理")
    @PostMapping("/analyze-files/confirm")
    public AjaxResult confirmAnalyzeResult(@RequestBody PurchaseAiConfirmRequest request) {
        if (request == null || !StringUtils.hasText(request.getBusinessType())) {
            return AjaxResult.error("businessType不能为空");
        }
        if (request.getPayload() == null || request.getPayload().isEmpty()) {
            return AjaxResult.error("payload不能为空");
        }
        try {
            String businessType = request.getBusinessType().trim();
            return switch (businessType) {
                case "purchase_ledger" -> processPurchaseLedger(request.getPayload());
                case "payment_registration" -> processPaymentRegistration(request.getPayload());
                case "purchase_return_order" -> processPurchaseReturnOrder(request.getPayload());
                default -> AjaxResult.error("暂不支持该业务类型: " + businessType);
            };
        } catch (Exception ex) {
            return AjaxResult.error(toCustomerMessage(ex));
        }
        return purchaseAiService.confirmAnalyzeResult(request);
    }
    @Operation(summary = "采购会话列表")
    @GetMapping("/history/sessions")
    public AjaxResult listSessions() {
        return success(aiChatSessionService.listCurrentUserSessions(SecurityUtils.getLoginUser()));
        LoginUser loginUser = SecurityUtils.getLoginUser();
        return success(purchaseAiService.listSessions(loginUser));
    }
    @Operation(summary = "采购会话消息")
    @GetMapping("/history/messages/{memoryId}")
    public AjaxResult listMessages(@PathVariable String memoryId) {
        return success(aiChatSessionService.listCurrentUserMessages(memoryId, SecurityUtils.getLoginUser()));
        LoginUser loginUser = SecurityUtils.getLoginUser();
        return success(purchaseAiService.listMessages(memoryId, loginUser));
    }
    @Operation(summary = "删除采购会话")
    @DeleteMapping("/history/{memoryId}")
    public AjaxResult deleteSession(@PathVariable String memoryId) {
        aiSessionUserContext.remove(memoryId);
        return toAjax(aiChatSessionService.deleteCurrentUserSession(memoryId, SecurityUtils.getLoginUser()));
    }
    private String buildMultiFileContent(MultipartFile[] files) throws IOException {
        StringBuilder builder = new StringBuilder();
        int totalLength = 0;
        for (MultipartFile file : files) {
            String text = aiFileTextExtractor.extractText(file);
            if (!StringUtils.hasText(text)) {
                continue;
            }
            String limitedText = text.length() > MAX_SINGLE_FILE_TEXT_LENGTH
                    ? text.substring(0, MAX_SINGLE_FILE_TEXT_LENGTH)
                    : text;
            if (totalLength + limitedText.length() > MAX_TOTAL_FILE_TEXT_LENGTH) {
                int remain = MAX_TOTAL_FILE_TEXT_LENGTH - totalLength;
                if (remain <= 0) {
                    break;
                }
                limitedText = limitedText.substring(0, remain);
            }
            builder.append("\n--- æ–‡ä»¶: ")
                    .append(file.getOriginalFilename())
                    .append(" ---\n")
                    .append(limitedText)
                    .append('\n');
            totalLength += limitedText.length();
        }
        return builder.toString();
    }
    private boolean containsImageFile(MultipartFile[] files) {
        for (MultipartFile file : files) {
            if (aiFileTextExtractor.isImageFile(file)) {
                return true;
            }
        }
        return false;
    }
    private Flux<String> chatWithPurchaseVisionModel(String memoryId, String userPrompt, MultipartFile[] files) {
        return Flux.create(sink -> {
            try {
                List<Content> contents = new ArrayList<>();
                contents.add(TextContent.from(userPrompt));
                for (MultipartFile file : files) {
                    if (!aiFileTextExtractor.isImageFile(file)) {
                        continue;
                    }
                    contents.add(TextContent.from("下面这张图片文件名:" + file.getOriginalFilename()));
                    contents.add(ImageContent.from(Image.builder()
                            .base64Data(Base64.getEncoder().encodeToString(file.getBytes()))
                            .mimeType(resolveImageMimeType(file))
                            .build()));
                }
                List<ChatMessage> messages = List.of(
                        SystemMessage.from("你是采购业务文件分析助手。请从文本和图片中识别采购台账、采购产品明细、付款或退货信息,只输出合法 JSON。"),
                        UserMessage.from(contents)
                );
                purchaseVisionStreamingChatModel.chat(messages, new StreamingChatResponseHandler() {
                    @Override
                    public void onPartialResponse(String partialResponse) {
                        sink.next(partialResponse);
                    }
                    @Override
                    public void onCompleteResponse(ChatResponse completeResponse) {
                        sink.complete();
                    }
                    @Override
                    public void onError(Throwable error) {
                        sink.error(error);
                    }
                });
            } catch (Exception ex) {
                sink.next("图片文件读取失败,请确认图片格式为 png、jpg、jpeg、webp æˆ– bmp,且大小不超过10MB");
                sink.complete();
            }
        });
    }
    private String resolveImageMimeType(MultipartFile file) {
        String contentType = file.getContentType();
        if (StringUtils.hasText(contentType) && contentType.startsWith("image/")) {
            return contentType;
        }
        String filename = file.getOriginalFilename();
        String ext = "";
        if (StringUtils.hasText(filename) && filename.contains(".")) {
            ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        }
        return switch (ext) {
            case "jpg", "jpeg" -> "image/jpeg";
            case "webp" -> "image/webp";
            case "bmp" -> "image/bmp";
            default -> "image/png";
        };
    }
    private String buildPurchaseFileAnalyzePrompt(String message, String fileContent) {
        return """
                ä½ æ˜¯é‡‡è´­ä¸šåŠ¡æ–‡ä»¶åˆ†æžåŠ©æ‰‹ã€‚è¯·ä¸¥æ ¼æ ¹æ®ç”¨æˆ·ä¸Šä¼ çš„å¤šä¸ªæ–‡ä»¶å’Œç”¨æˆ·è¦æ±‚æå–é‡‡è´­ä¸šåŠ¡æ•°æ®ã€‚
                ç”¨æˆ·è¦æ±‚:
                %s
                è¾“出要求:
                1. åªè¾“出合法 JSON,不要 Markdown,不要额外解释。
                2. JSON é¡¶å±‚字段固定为:
                   - success: boolean
                   - businessType: purchase_ledger | payment_registration | purchase_return_order | unknown
                   - action: confirm_required
                   - description: ä¸­æ–‡è¯´æ˜Ž
                   - confidence: 0到1的小数
                   - missingFields: ç¼ºå¤±å­—段中文名称数组,面向客户展示,不要输出英文字段名
                   - warnings: é£Žé™©æç¤ºæ•°ç»„
                   - payload: å¾…客户确认的数据,字段名必须使用后端 DTO å­—段名
                   - preview: ç»™å®¢æˆ·ç¡®è®¤ç”¨çš„中文摘要数组
                3. å¦‚果可判断为采购台账,businessType ä½¿ç”¨ purchase_ledger,payload.purchaseLedgers ä¸ºé‡‡è´­è®¢å•/采购台账数组:
                   - purchaseLedgers: é‡‡è´­è®¢å•/采购台账数组,每条记录字段名必须与 PurchaseLedgerDto ä¿æŒä¸€è‡´
                   - äº§å“æ˜Žç»†å¿…须放在每条采购台账记录的 productData å­—段中,productData ç±»åž‹ä¸º List<SalesLedgerProduct>
                   - ä¸è¦ä¼˜å…ˆä½¿ç”¨ payload é¡¶å±‚ productData;顶层 productData ä»…作为旧格式兼容
                   - æ–‡ä»¶é‡Œçš„“采购单号”就是“采购合同号”,统一映射为 purchaseContractNumber
                   - æ–‡ä»¶é‡Œçš„“销售单号”就是“销售合同号”,统一映射为 salesContractNo
                   - æ‰€æœ‰æ—¥æœŸå­—段必须使用 yyyy-MM-dd,例如 2026-04-30;不要输出 4/30/26、2026/4/30、2026å¹´4月30日 æˆ–带时分秒的格式
                   - é‡‡è´­å°è´¦ä¸éœ€è¦åœ¨ payload ä¸­ä¼ å®¡æ‰¹äººï¼Œä¸è¦è¾“出 approveUserIds、approverId
                   - missingFields åªå¡«å†™ä¸šåŠ¡å¿…å¡«ä½†æ— æ³•è¯†åˆ«çš„å­—æ®µï¼Œä¸è¦æŠŠ PurchaseLedgerDto çš„æ‰€æœ‰ç©ºå­—段都列为缺失;缺失项必须写中文,例如“供应商名称”“含税单价”,不要写 supplierId、taxInclusiveUnitPrice
                   - é‡‡è´­å°è´¦ä¸»è¡¨å¿…填字段仅按这些判断: purchaseContractNumber、supplierName æˆ– supplierId
                   - productData æ¯æ¡äº§å“å¿…填字段: productCategory、specificationModel、unit、quantity、taxInclusiveUnitPrice æˆ– taxInclusiveTotalPrice;如果只有含税总价和数量,必须计算 taxInclusiveUnitPrice;如果只有含税单价和数量,必须计算 taxInclusiveTotalPrice
                   - äº§å“å­—段按采购导入接口 PurchaseLedgerProductImportDto å¯¹é½: é‡‡è´­å•号、产品大类、规格型号、单位、数量、税率、含税单价、含税总价、发票类型、是否质检
                   - é‡‡è´­äº§å“ type å›ºå®šä¸º 2
                   - purchaseLedgers æ¯æ¡è®°å½•只使用这些 PurchaseLedgerDto å­—段名:
                     entryDateStart, entryDateEnd, id, purchaseContractNumber, supplierId, supplierName, isWhite, recorderId, recorderName, salesContractNo, salesContractNoId, projectName, entryDate, executionDate, remarks, attachmentMaterials, createdAt, updatedAt, salesLedgerId, hasChildren, Type, productData, tempFileIds, SalesLedgerFiles, phoneNumber, businessPersonId, productId, productModelId, invoiceNumber, invoiceAmount, ticketRegistrationId, contractAmount, receiptPaymentAmount, unReceiptPaymentAmount, type, paymentMethod, approvalStatus, templateName
                   - productData æ¯æ¡äº§å“åªä½¿ç”¨è¿™äº› SalesLedgerProduct å­—段名:
                     productCategory, specificationModel, unit, quantity, taxRate, taxInclusiveUnitPrice, taxInclusiveTotalPrice, taxExclusiveTotalPrice, invoiceType, productId, productModelId, isChecked, type
                4. å¦‚果可判断为付款登记,businessType ä½¿ç”¨ payment_registration,payload.records ä¸ºä»˜æ¬¾ç™»è®°æ•°ç»„,字段尽量包含 purchaseLedgerId、salesLedgerProductId、currentPaymentAmount、paymentMethod、paymentDate。
                5. å¦‚果可判断为采购退货,businessType ä½¿ç”¨ purchase_return_order,payload æŒ‰ PurchaseReturnOrderDto ç»„织,明细放 purchaseReturnOrderProductsDtos。
                6. ç¼ºå°‘业务处理必须字段时,不要编造 ID,把字段放入 missingFields,并仍返回可确认的草稿数据。
                7. æ‰€æœ‰ä¸­æ–‡å†…容直接保留,不要转义成 Unicode。
                æ–‡ä»¶å†…容:
                %s
                """.formatted(message, fileContent);
    }
    private AjaxResult processPurchaseLedger(Map<String, Object> payload) throws Exception {
        if (payload.containsKey("purchaseLedgers")) {
            return processPurchaseLedgerBatch(payload);
        }
        Map<String, Object> normalizedPayload = normalizePurchaseLedgerMap(payload);
        PurchaseLedgerDto dto = objectMapper.convertValue(normalizedPayload, PurchaseLedgerDto.class);
        AjaxResult ledgerResult = validatePurchaseLedger(dto, 0);
        if (ledgerResult != null) {
            return ledgerResult;
        }
        AjaxResult supplierResult = fillSupplierIdByName(dto);
        if (supplierResult != null) {
            return supplierResult;
        }
        AjaxResult productResult = validatePurchaseProducts(dto.getProductData(), 0);
        if (productResult != null) {
            return productResult;
        }
        int result = purchaseLedgerService.addOrEditPurchase(dto);
        return AjaxResult.success("采购台账已处理", result);
    }
    private AjaxResult processPurchaseLedgerBatch(Map<String, Object> payload) throws Exception {
        List<Map<String, Object>> purchaseLedgers = toMapList(payload.get("purchaseLedgers"));
        if (purchaseLedgers.isEmpty()) {
            return AjaxResult.error("purchaseLedgers不能为空");
        }
        List<Map<String, Object>> topLevelProductData = toMapList(payload.get("productData"));
        List<Map<String, Object>> results = new ArrayList<>();
        for (int i = 0; i < purchaseLedgers.size(); i++) {
            Map<String, Object> ledgerMap = normalizePurchaseLedgerMap(purchaseLedgers.get(i));
            PurchaseLedgerDto dto = objectMapper.convertValue(ledgerMap, PurchaseLedgerDto.class);
            AjaxResult ledgerResult = validatePurchaseLedger(dto, i);
            if (ledgerResult != null) {
                return ledgerResult;
            }
            AjaxResult supplierResult = fillSupplierIdByName(dto);
            if (supplierResult != null) {
                return supplierResult;
            }
            List<SalesLedgerProduct> products = dto.getProductData();
            if (products == null || products.isEmpty()) {
                products = matchProductsForLedger(ledgerMap, dto, topLevelProductData, purchaseLedgers.size() == 1);
                dto.setProductData(products);
            }
            AjaxResult productResult = validatePurchaseProducts(products, i);
            if (productResult != null) {
                return productResult;
            }
            int result = purchaseLedgerService.addOrEditPurchase(dto);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("index", i);
            item.put("purchaseContractNumber", dto.getPurchaseContractNumber());
            item.put("supplierId", dto.getSupplierId());
            item.put("supplierName", dto.getSupplierName());
            item.put("productCount", products.size());
            item.put("result", result);
            results.add(item);
        }
        return AjaxResult.success("采购台账已批量处理", results);
    }
    private List<SalesLedgerProduct> matchProductsForLedger(Map<String, Object> ledgerMap,
                                                            PurchaseLedgerDto dto,
                                                            List<Map<String, Object>> productData,
                                                            boolean onlyOneLedger) {
        List<SalesLedgerProduct> products = new ArrayList<>();
        for (Map<String, Object> productMap : productData) {
            if (onlyOneLedger || productBelongsToLedger(productMap, ledgerMap, dto)) {
                products.add(objectMapper.convertValue(normalizeSalesLedgerProductMap(productMap), SalesLedgerProduct.class));
            }
        }
        return products;
    }
    private boolean productBelongsToLedger(Map<String, Object> productMap, Map<String, Object> ledgerMap, PurchaseLedgerDto dto) {
        Long productPurchaseLedgerId = longValue(productMap, "purchaseLedgerId", "purchaseId", "采购订单id", "采购台账id");
        if (productPurchaseLedgerId != null && dto.getId() != null && productPurchaseLedgerId.equals(dto.getId())) {
            return true;
        }
        Long productSalesLedgerId = longValue(productMap, "salesLedgerId");
        if (productSalesLedgerId != null && dto.getId() != null && productSalesLedgerId.equals(dto.getId())) {
            return true;
        }
        String productContractNo = stringValue(productMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(dto.getPurchaseContractNumber())
                && productContractNo.trim().equals(dto.getPurchaseContractNumber().trim())) {
            return true;
        }
        String ledgerContractNo = stringValue(ledgerMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(ledgerContractNo)
                && productContractNo.trim().equals(ledgerContractNo.trim())) {
            return true;
        }
        String productSalesContractNo = stringValue(productMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(dto.getSalesContractNo())
                && productSalesContractNo.trim().equals(dto.getSalesContractNo().trim())) {
            return true;
        }
        String ledgerSalesContractNo = stringValue(ledgerMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(ledgerSalesContractNo)
                && productSalesContractNo.trim().equals(ledgerSalesContractNo.trim())) {
            return true;
        }
        String productSupplierName = stringValue(productMap, "supplierName", "供应商名称");
        return StringUtils.hasText(productSupplierName)
                && StringUtils.hasText(dto.getSupplierName())
                && productSupplierName.trim().equals(dto.getSupplierName().trim());
    }
    private Map<String, Object> normalizePurchaseLedgerMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copyPurchaseLedgerDtoFields(source, target);
        putDtoFieldIfPresent(source, target, "entryDateStart", "录入开始日期", "录入日期开始");
        putDtoFieldIfPresent(source, target, "entryDateEnd", "录入结束日期", "录入日期结束");
        putDtoFieldIfPresent(source, target, "id", "采购台账id", "采购订单id", "主键");
        putDtoFieldIfPresent(source, target, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        putDtoFieldIfPresent(source, target, "supplierId", "供应商id", "供应商ID", "供应商名称id", "供应商名称ID");
        putDtoFieldIfPresent(source, target, "supplierName", "供应商", "供应商名称");
        putDtoFieldIfPresent(source, target, "isWhite", "是否白名单");
        putDtoFieldIfPresent(source, target, "recorderId", "录入人id", "录入人ID", "录入人姓名id", "录入人姓名ID");
        putDtoFieldIfPresent(source, target, "recorderName", "录入人", "录入人姓名");
        putDtoFieldIfPresent(source, target, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        putDtoFieldIfPresent(source, target, "salesContractNoId", "销售合同号id", "销售合同号ID", "销售单号id", "销售单号ID");
        putDtoFieldIfPresent(source, target, "projectName", "项目", "项目名称");
        putDtoFieldIfPresent(source, target, "entryDate", "录入日期");
        putDtoFieldIfPresent(source, target, "executionDate", "签订日期", "合同签订日期");
        putDtoFieldIfPresent(source, target, "remarks", "备注", "说明");
        putDtoFieldIfPresent(source, target, "attachmentMaterials", "附件材料", "附件材料路径或名称");
        putDtoFieldIfPresent(source, target, "createdAt", "创建时间", "记录创建时间");
        putDtoFieldIfPresent(source, target, "updatedAt", "更新时间", "记录最后更新时间");
        putDtoFieldIfPresent(source, target, "salesLedgerId", "销售台账id", "销售台账ID", "关联销售台账主表主键");
        putDtoFieldIfPresent(source, target, "hasChildren", "是否有子级", "是否有明细");
        putDtoFieldIfPresent(source, target, "Type", "台账类型", "业务类型");
        putDtoFieldIfPresent(source, target, "productData", "products", "产品明细", "采购产品明细");
        putDtoFieldIfPresent(source, target, "tempFileIds", "临时文件id", "临时文件ID", "临时文件ids");
        putDtoFieldIfPresent(source, target, "SalesLedgerFiles", "附件列表", "销售台账附件");
        putDtoFieldIfPresent(source, target, "phoneNumber", "业务员手机号", "手机号");
        putDtoFieldIfPresent(source, target, "businessPersonId", "业务员id", "业务员ID");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID");
        putDtoFieldIfPresent(source, target, "invoiceNumber", "发票号", "发票号码");
        putDtoFieldIfPresent(source, target, "invoiceAmount", "发票金额", "发票金额(元)");
        putDtoFieldIfPresent(source, target, "ticketRegistrationId", "来票登记id", "来票登记ID");
        putDtoFieldIfPresent(source, target, "contractAmount", "合同金额", "合同金额(产品含税总价)");
        putDtoFieldIfPresent(source, target, "receiptPaymentAmount", "来票金额", "已来票金额", "已来票金额(元)");
        putDtoFieldIfPresent(source, target, "unReceiptPaymentAmount", "未来票金额", "未来票金额(元)");
        putDtoFieldIfPresent(source, target, "type", "文件类型");
        putDtoFieldIfPresent(source, target, "paymentMethod", "付款方式");
        putDtoFieldIfPresent(source, target, "approvalStatus", "审批状态");
        putDtoFieldIfPresent(source, target, "templateName", "模板名称");
        target.remove("approveUserIds");
        target.remove("approverId");
        normalizeNestedProductData(target);
        attachImportStyleProductData(source, target);
        if (target.get("type") == null) {
            target.put("type", 2);
        }
        target.putIfAbsent("entryDate", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
        normalizePurchaseLedgerDateFields(target);
        return target;
    }
    private void attachImportStyleProductData(Map<String, Object> source, Map<String, Object> target) {
        if (target.get("productData") != null) {
            return;
        }
        Map<String, Object> productMap = normalizeSalesLedgerProductMap(source);
        if (hasImportStyleProductData(productMap)) {
            target.put("productData", List.of(productMap));
        }
    }
    private boolean hasImportStyleProductData(Map<String, Object> productMap) {
        return hasMapText(productMap, "productCategory")
                || hasMapText(productMap, "specificationModel")
                || productMap.get("quantity") != null
                || productMap.get("taxInclusiveUnitPrice") != null
                || productMap.get("taxInclusiveTotalPrice") != null;
    }
    private boolean hasMapText(Map<String, Object> map, String key) {
        Object value = map.get(key);
        return value != null && StringUtils.hasText(String.valueOf(value));
    }
    private void normalizeNestedProductData(Map<String, Object> target) {
        Object productDataValue = target.get("productData");
        if (productDataValue == null) {
            return;
        }
        List<Map<String, Object>> productMaps = toMapList(productDataValue);
        List<Map<String, Object>> normalizedProducts = new ArrayList<>();
        for (Map<String, Object> productMap : productMaps) {
            normalizedProducts.add(normalizeSalesLedgerProductMap(productMap));
        }
        target.put("productData", normalizedProducts);
    }
    private Map<String, Object> normalizeSalesLedgerProductMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copySalesLedgerProductFields(source, target);
        putDtoFieldIfPresent(source, target, "productCategory", "产品大类", "产品名称", "产品", "品名", "物料名称");
        putDtoFieldIfPresent(source, target, "specificationModel", "规格型号", "型号", "规格", "产品规格");
        putDtoFieldIfPresent(source, target, "unit", "单位");
        putDtoFieldIfPresent(source, target, "quantity", "数量", "采购数量");
        putDtoFieldIfPresent(source, target, "taxRate", "税率");
        putDtoFieldIfPresent(source, target, "taxInclusiveUnitPrice", "含税单价", "单价", "采购单价", "含税价格");
        putDtoFieldIfPresent(source, target, "taxInclusiveTotalPrice", "含税总价", "总价", "采购金额", "金额", "合同金额");
        putDtoFieldIfPresent(source, target, "taxExclusiveTotalPrice", "不含税总价");
        putDtoFieldIfPresent(source, target, "invoiceType", "发票类型", "发票类别");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID", "型号id", "型号ID");
        putDtoFieldIfPresent(source, target, "isChecked", "是否质检", "是否质检验", "质检");
        putDtoFieldIfPresent(source, target, "type", "台账类型");
        normalizeProductAmounts(target);
        target.putIfAbsent("type", 2);
        return target;
    }
    private void copySalesLedgerProductFields(Map<String, Object> source, Map<String, Object> target) {
        String[] productFields = {
                "id", "salesLedgerId", "warnNum", "productCategory", "specificationModel", "unit",
                "speculativeTradingName", "quantity", "minStock", "taxRate", "taxInclusiveUnitPrice",
                "taxInclusiveTotalPrice", "taxExclusiveTotalPrice", "invoiceType", "type", "ticketsNum",
                "ticketsAmount", "futureTickets", "futureTicketsAmount", "invoiceNum", "noInvoiceNum",
                "invoiceAmount", "noInvoiceAmount", "productId", "productModelId", "register", "registerDate",
                "approveStatus", "pendingInvoiceTotal", "invoiceTotal", "pendingTicketsTotal", "ticketsTotal",
                "isChecked", "isProduction"
        };
        for (String field : productFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void normalizeProductAmounts(Map<String, Object> target) {
        BigDecimal quantity = decimalValue(target.get("quantity"));
        BigDecimal unitPrice = decimalValue(target.get("taxInclusiveUnitPrice"));
        BigDecimal totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (unitPrice == null && totalPrice != null && quantity != null && quantity.compareTo(BigDecimal.ZERO) != 0) {
            target.put("taxInclusiveUnitPrice", totalPrice.divide(quantity, 6, RoundingMode.HALF_UP));
        }
        if (totalPrice == null && unitPrice != null && quantity != null) {
            target.put("taxInclusiveTotalPrice", unitPrice.multiply(quantity));
        }
        BigDecimal taxRate = decimalValue(target.get("taxRate"));
        totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (target.get("taxExclusiveTotalPrice") == null && totalPrice != null && taxRate != null) {
            BigDecimal divisor = BigDecimal.ONE.add(taxRate.divide(new BigDecimal("100"), 6, RoundingMode.HALF_UP));
            target.put("taxExclusiveTotalPrice", totalPrice.divide(divisor, 2, RoundingMode.HALF_UP));
        }
    }
    private AjaxResult validatePurchaseProducts(List<SalesLedgerProduct> products, int ledgerIndex) {
        if (products == null || products.isEmpty()) {
            return null;
        }
        for (int i = 0; i < products.size(); i++) {
            SalesLedgerProduct product = products.get(i);
            String prefix = "第" + (ledgerIndex + 1) + "个采购台账的第" + (i + 1) + "条产品";
            if (!StringUtils.hasText(product.getProductCategory())) {
                return AjaxResult.error(prefix + "缺少产品名称,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getSpecificationModel())) {
                return AjaxResult.error(prefix + "缺少规格型号,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getUnit())) {
                return AjaxResult.error(prefix + "缺少单位,请补充后再确认");
            }
            if (product.getQuantity() == null) {
                return AjaxResult.error(prefix + "缺少数量");
            }
            if (product.getTaxInclusiveUnitPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税单价,请补充后再确认");
            }
            if (product.getTaxInclusiveTotalPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税总价,请补充后再确认");
            }
        }
        return null;
    }
    private AjaxResult validatePurchaseLedger(PurchaseLedgerDto dto, int ledgerIndex) {
        String prefix = "第" + (ledgerIndex + 1) + "个采购台账";
        if (!StringUtils.hasText(dto.getPurchaseContractNumber())) {
            return AjaxResult.error(prefix + "缺少采购合同号,请补充后再确认");
        }
        if (dto.getSupplierId() == null && !StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error(prefix + "缺少供应商名称,请补充后再确认");
        }
        return null;
    }
    private void normalizePurchaseLedgerDateFields(Map<String, Object> target) {
        normalizeDateField(target, "entryDate");
        normalizeDateField(target, "executionDate");
        normalizeDateField(target, "createdAt");
        normalizeDateField(target, "updatedAt");
    }
    private void normalizeDateField(Map<String, Object> target, String fieldName) {
        Object value = target.get(fieldName);
        if (value == null) {
            return;
        }
        String normalizedDate = normalizeDateValue(value);
        if (StringUtils.hasText(normalizedDate)) {
            target.put(fieldName, normalizedDate);
        }
    }
    private String normalizeDateValue(Object value) {
        if (value instanceof Date date) {
            return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        if (value instanceof Number number) {
            return LocalDate.of(1899, 12, 30)
                    .plusDays(number.longValue())
                    .format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        String text = String.valueOf(value).trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        if (text.length() >= 10 && text.charAt(4) == '-' && text.charAt(7) == '-') {
            return text.substring(0, 10);
        }
        String normalizedText = text.replace("å¹´", "-")
                .replace("月", "-")
                .replace("日", "")
                .replace(".", "-")
                .replace("/", "-")
                .trim();
        DateTimeFormatter[] formatters = {
                DateTimeFormatter.ofPattern("yyyy-M-d"),
                DateTimeFormatter.ofPattern("M-d-yyyy"),
                DateTimeFormatter.ofPattern("M-d-yy")
        };
        for (DateTimeFormatter formatter : formatters) {
            try {
                return LocalDate.parse(normalizedText, formatter).format(DateTimeFormatter.ISO_LOCAL_DATE);
            } catch (DateTimeParseException ignored) {
                // Try the next supported input pattern.
            }
        }
        return text;
    }
    private void copyPurchaseLedgerDtoFields(Map<String, Object> source, Map<String, Object> target) {
        String[] dtoFields = {
                "entryDateStart", "entryDateEnd", "id", "purchaseContractNumber",
                "supplierId", "supplierName", "isWhite", "recorderId", "recorderName", "salesContractNo",
                "salesContractNoId", "projectName", "entryDate", "executionDate", "remarks", "attachmentMaterials",
                "createdAt", "updatedAt", "salesLedgerId", "hasChildren", "Type", "productData", "tempFileIds",
                "SalesLedgerFiles", "phoneNumber", "businessPersonId", "productId", "productModelId", "invoiceNumber",
                "invoiceAmount", "ticketRegistrationId", "contractAmount", "receiptPaymentAmount",
                "unReceiptPaymentAmount", "type", "paymentMethod", "approvalStatus", "templateName"
        };
        for (String field : dtoFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void putDtoFieldIfPresent(Map<String, Object> source, Map<String, Object> target, String dtoField, String... aliases) {
        if (target.containsKey(dtoField) && target.get(dtoField) != null) {
            return;
        }
        for (String alias : aliases) {
            Object value = source.get(alias);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                target.put(dtoField, value);
                return;
            }
        }
    }
    private List<Map<String, Object>> toMapList(Object value) {
        if (value == null) {
            return List.of();
        }
        return objectMapper.convertValue(value, new TypeReference<List<Map<String, Object>>>() {
        });
    }
    private String stringValue(Map<String, Object> map, String... keys) {
        for (String key : keys) {
            Object value = map.get(key);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                return String.valueOf(value);
            }
        }
        return null;
    }
    private Long longValue(Map<String, Object> map, String... keys) {
        String value = stringValue(map, keys);
        if (!StringUtils.hasText(value)) {
            return null;
        }
        try {
            return Long.parseLong(value.trim());
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof BigDecimal decimal) {
            return decimal;
        }
        if (value instanceof Number number) {
            return new BigDecimal(String.valueOf(number));
        }
        String text = String.valueOf(value)
                .replace(",", "")
                .replace(",", "")
                .replace("元", "")
                .replace("ï¿¥", "")
                .trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        try {
            return new BigDecimal(text);
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private String toCustomerMessage(Exception ex) {
        String message = ex.getMessage();
        if (!StringUtils.hasText(message)) {
            return "处理失败,请检查确认数据后重试";
        }
        if (message.contains("tax_inclusive_unit_price")) {
            return "处理失败:产品明细缺少含税单价,请补充后再确认";
        }
        if (message.contains("tax_inclusive_total_price")) {
            return "处理失败:产品明细缺少含税总价,请补充后再确认";
        }
        if (message.contains("entryDate")) {
            return "处理失败:录入日期格式不正确,请使用 yyyy-MM-dd,例如 2026-04-30";
        }
        if (message.contains("supplier")) {
            return "处理失败:供应商信息不完整,请确认供应商名称或供应商ID";
        }
        if (message.contains("SQL") || message.contains("java.") || message.contains("Exception")) {
            return "处理失败:确认数据不完整或格式不正确,请检查必填字段后重试";
        }
        return "处理失败:" + message;
    }
    private AjaxResult fillSupplierIdByName(PurchaseLedgerDto dto) {
        if (dto.getSupplierId() != null) {
            return null;
        }
        if (!StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error("供应商ID不能为空;未识别到供应商名称,无法自动匹配供应商ID");
        }
        SupplierManage supplier = supplierManageMapper.selectOne(new LambdaQueryWrapper<SupplierManage>()
                .eq(SupplierManage::getSupplierName, dto.getSupplierName().trim())
                .last("limit 1"));
        if (supplier == null) {
            return AjaxResult.error("未找到供应商:" + dto.getSupplierName() + ",请先维护供应商或手动选择供应商ID");
        }
        dto.setSupplierId(supplier.getId());
        return null;
    }
    private AjaxResult processPaymentRegistration(Map<String, Object> payload) {
        Object recordsValue = payload.get("records");
        List<PaymentRegistration> records;
        if (recordsValue == null) {
            records = Collections.singletonList(objectMapper.convertValue(payload, PaymentRegistration.class));
        } else {
            records = objectMapper.convertValue(recordsValue, new TypeReference<List<PaymentRegistration>>() {
            });
        }
        int result = paymentRegistrationService.insertPaymentRegistration(records);
        return AjaxResult.success("付款登记已处理", result);
    }
    private AjaxResult processPurchaseReturnOrder(Map<String, Object> payload) {
        PurchaseReturnOrderDto dto = objectMapper.convertValue(payload, PurchaseReturnOrderDto.class);
        Boolean result = purchaseReturnOrdersService.add(dto);
        return AjaxResult.success("采购退货单已处理", result);
        LoginUser loginUser = SecurityUtils.getLoginUser();
        return toAjax(purchaseAiService.deleteSession(memoryId, loginUser));
    }
}
src/main/java/com/ruoyi/ai/dto/AiChatMessageDto.java
@@ -1,15 +1,28 @@
package com.ruoyi.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AiChatMessageDto {
    private String role;
    private String content;
    private List<String> filePaths;
    public AiChatMessageDto(String role, String content) {
        this.role = role;
        this.content = content;
    }
    public AiChatMessageDto(String role, String content, List<String> filePaths) {
        this.role = role;
        this.content = content;
        this.filePaths = filePaths;
    }
}
src/main/java/com/ruoyi/ai/mongodbBean/ChatMessages.java
@@ -9,6 +9,7 @@
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Date;
import java.util.List;
@Data
@AllArgsConstructor
@@ -24,6 +25,21 @@
    private String content;
    /**
     * å¤šæ–‡ä»¶åˆ†æžç”¨æˆ·æé—®ä¿¡æ¯ï¼ˆä¸Žæ–‡ä»¶è·¯å¾„分开存储)
     */
    private List<String> analyzeUserQuestions;
    /**
     * å¤šæ–‡ä»¶åˆ†æžä¸Šä¼ æ–‡ä»¶è·¯å¾„(图片和 pdf ä½¿ç”¨é¢„览地址,其他使用下载地址)
     */
    private List<String> analyzeFilePaths;
    /**
     * å¤šæ–‡ä»¶åˆ†æžæ¯æ¬¡æé—®å¯¹åº”的文件路径分组
     */
    private List<List<String>> analyzeFilePathGroups;
    private Date createTime;
    private Date updateTime;
src/main/java/com/ruoyi/ai/service/PurchaseAiService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1031 @@
package com.ruoyi.ai.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ai.assistant.PurchaseAgent;
import com.ruoyi.ai.assistant.PurchaseIntentExecutor;
import com.ruoyi.ai.bean.ChatForm;
import com.ruoyi.ai.bean.PurchaseAiConfirmRequest;
import com.ruoyi.ai.context.AiSessionUserContext;
import com.ruoyi.ai.dto.AiChatMessageDto;
import com.ruoyi.ai.dto.AiChatSessionDto;
import com.ruoyi.ai.store.MongoChatMemoryStore;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.basic.mapper.SupplierManageMapper;
import com.ruoyi.basic.pojo.SupplierManage;
import com.ruoyi.basic.service.StorageBlobService;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.purchase.dto.PurchaseLedgerDto;
import com.ruoyi.purchase.dto.PurchaseReturnOrderDto;
import com.ruoyi.purchase.pojo.PaymentRegistration;
import com.ruoyi.purchase.service.IPaymentRegistrationService;
import com.ruoyi.purchase.service.IPurchaseLedgerService;
import com.ruoyi.purchase.service.PurchaseReturnOrdersService;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import dev.langchain4j.data.image.Image;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Base64;
import java.util.Arrays;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.nio.file.Files;
@Service
public class PurchaseAiService {
    private static final String PURCHASE_FILE_ANALYZE_MEMORY_PREFIX = "purchase-file-analyze::";
    private static final int MAX_FILE_COUNT = 10;
    private static final int MAX_SINGLE_FILE_TEXT_LENGTH = 8000;
    private static final int MAX_TOTAL_FILE_TEXT_LENGTH = 30000;
    private final PurchaseAgent purchaseAgent;
    private final PurchaseIntentExecutor purchaseIntentExecutor;
    private final AiSessionUserContext aiSessionUserContext;
    private final MongoChatMemoryStore mongoChatMemoryStore;
    private final AiChatSessionService aiChatSessionService;
    private final AiFileTextExtractor aiFileTextExtractor;
    private final ObjectMapper objectMapper;
    private final IPurchaseLedgerService purchaseLedgerService;
    private final IPaymentRegistrationService paymentRegistrationService;
    private final PurchaseReturnOrdersService purchaseReturnOrdersService;
    private final StorageBlobService storageBlobService;
    private final SupplierManageMapper supplierManageMapper;
    private final StreamingChatLanguageModel purchaseVisionStreamingChatModel;
    public PurchaseAiService(PurchaseAgent purchaseAgent,
                                PurchaseIntentExecutor purchaseIntentExecutor,
                                AiSessionUserContext aiSessionUserContext,
                                MongoChatMemoryStore mongoChatMemoryStore,
                                AiChatSessionService aiChatSessionService,
                                AiFileTextExtractor aiFileTextExtractor,
                                 ObjectMapper objectMapper,
                                 IPurchaseLedgerService purchaseLedgerService,
                                 IPaymentRegistrationService paymentRegistrationService,
                                 PurchaseReturnOrdersService purchaseReturnOrdersService,
                                 StorageBlobService storageBlobService,
                                 SupplierManageMapper supplierManageMapper,
                                 @Qualifier("purchaseVisionStreamingChatModel") StreamingChatLanguageModel purchaseVisionStreamingChatModel) {
        this.purchaseAgent = purchaseAgent;
        this.purchaseIntentExecutor = purchaseIntentExecutor;
        this.aiSessionUserContext = aiSessionUserContext;
        this.mongoChatMemoryStore = mongoChatMemoryStore;
        this.aiChatSessionService = aiChatSessionService;
        this.aiFileTextExtractor = aiFileTextExtractor;
        this.objectMapper = objectMapper;
        this.purchaseLedgerService = purchaseLedgerService;
        this.paymentRegistrationService = paymentRegistrationService;
        this.purchaseReturnOrdersService = purchaseReturnOrdersService;
        this.storageBlobService = storageBlobService;
        this.supplierManageMapper = supplierManageMapper;
        this.purchaseVisionStreamingChatModel = purchaseVisionStreamingChatModel;
    }
    public Flux<String> chat(ChatForm chatForm, LoginUser loginUser) {
        if (!StringUtils.hasText(chatForm.getMemoryId())) {
            return Flux.just("memoryId不能为空");
        }
        if (!StringUtils.hasText(chatForm.getMessage())) {
            return Flux.just("message不能为空");
        }
        String memoryId = chatForm.getMemoryId();
        String userMessage = chatForm.getMessage();
        aiSessionUserContext.bind(memoryId, loginUser);
        aiChatSessionService.touchSession(memoryId, loginUser, userMessage);
        String directResponse = purchaseIntentExecutor.tryExecute(memoryId, userMessage);
        if (StringUtils.isNotEmpty(directResponse)) {
            mongoChatMemoryStore.appendMessages(
                    memoryId,
                    List.of(UserMessage.from(userMessage), AiMessage.from(directResponse))
            );
            aiChatSessionService.refreshSessionStats(memoryId, loginUser);
            return Flux.just(directResponse);
        }
        return purchaseAgent.chat(memoryId, userMessage)
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(memoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(memoryId, loginUser));
    }
    public Flux<String> analyzeFiles(MultipartFile[] files,
                                     String message,
                                     String memoryId,
                                     LoginUser loginUser) {
        if (files == null || files.length == 0) {
            return Flux.just("files不能为空");
        }
        if (files.length > MAX_FILE_COUNT) {
            return Flux.just("一次最多分析" + MAX_FILE_COUNT + "个文件");
        }
        String rawMemoryId = StringUtils.hasText(memoryId) ? memoryId : UUID.randomUUID().toString();
        String finalMemoryId = rawMemoryId.startsWith(PURCHASE_FILE_ANALYZE_MEMORY_PREFIX)
                ? rawMemoryId
                : PURCHASE_FILE_ANALYZE_MEMORY_PREFIX + rawMemoryId;
        aiSessionUserContext.bind(finalMemoryId, loginUser);
        String finalMessage = StringUtils.hasText(message)
                ? message
                : "请分析这些采购文件,提取可用于业务处理的数据,并整理成待客户确认的格式";
        List<String> filePaths;
        try {
            List<StorageBlobVO> uploadedFiles = storageBlobService.upload(copyFilesForUpload(files), true);
            filePaths = resolveFileAccessPaths(uploadedFiles);
        } catch (Exception ex) {
            return Flux.just("文件上传失败");
        }
        try {
            mongoChatMemoryStore.appendAnalyzeFileContext(finalMemoryId, finalMessage, filePaths);
        } catch (Exception ex) {
            return Flux.just("会话文件信息保存失败");
        }
        String fileContent;
        try {
            fileContent = buildMultiFileContent(files);
        } catch (IllegalArgumentException ex) {
            return Flux.just(ex.getMessage());
        } catch (IOException ex) {
            return Flux.just("文件读取失败");
        }
        if (!StringUtils.hasText(fileContent)) {
            return Flux.just("未提取到有效文件内容");
        }
        String userPrompt = buildPurchaseFileAnalyzePrompt(finalMessage, fileContent);
        aiChatSessionService.touchSession(finalMemoryId, loginUser, "采购多文件分析: " + finalMessage);
        if (containsImageFile(files)) {
            return chatWithPurchaseVisionModel(finalMemoryId, finalMessage, userPrompt, files)
                    .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                    .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
        }
        return Flux.defer(() -> purchaseAgent.chat(finalMemoryId, userPrompt))
                .onErrorResume(NoSuchElementException.class, ex -> {
                    mongoChatMemoryStore.deleteMessages(finalMemoryId);
                    return purchaseAgent.chat(finalMemoryId, userPrompt);
                })
                .doOnComplete(() -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser))
                .doOnError(ex -> aiChatSessionService.refreshSessionStats(finalMemoryId, loginUser));
    }
    public AjaxResult confirmAnalyzeResult(PurchaseAiConfirmRequest request) {
        if (request == null || !StringUtils.hasText(request.getBusinessType())) {
            return AjaxResult.error("businessType不能为空");
        }
        if (request.getPayload() == null || request.getPayload().isEmpty()) {
            return AjaxResult.error("payload不能为空");
        }
        try {
            String businessType = request.getBusinessType().trim();
            return switch (businessType) {
                case "purchase_ledger" -> processPurchaseLedger(request.getPayload());
                case "payment_registration" -> processPaymentRegistration(request.getPayload());
                case "purchase_return_order" -> processPurchaseReturnOrder(request.getPayload());
                default -> AjaxResult.error("暂不支持该业务类型: " + businessType);
            };
        } catch (Exception ex) {
            return AjaxResult.error(toCustomerMessage(ex));
        }
    }
    public List<AiChatSessionDto> listSessions(LoginUser loginUser) {
        return aiChatSessionService.listCurrentUserSessions(loginUser);
    }
    public List<AiChatMessageDto> listMessages(String memoryId, LoginUser loginUser) {
        return aiChatSessionService.listCurrentUserMessages(memoryId, loginUser);
    }
    public boolean deleteSession(String memoryId, LoginUser loginUser) {
        aiSessionUserContext.remove(memoryId);
        return aiChatSessionService.deleteCurrentUserSession(memoryId, loginUser);
    }
    private String buildMultiFileContent(MultipartFile[] files) throws IOException {
        StringBuilder builder = new StringBuilder();
        int totalLength = 0;
        for (MultipartFile file : files) {
            String text = aiFileTextExtractor.extractText(file);
            if (!StringUtils.hasText(text)) {
                continue;
            }
            String limitedText = text.length() > MAX_SINGLE_FILE_TEXT_LENGTH
                    ? text.substring(0, MAX_SINGLE_FILE_TEXT_LENGTH)
                    : text;
            if (totalLength + limitedText.length() > MAX_TOTAL_FILE_TEXT_LENGTH) {
                int remain = MAX_TOTAL_FILE_TEXT_LENGTH - totalLength;
                if (remain <= 0) {
                    break;
                }
                limitedText = limitedText.substring(0, remain);
            }
            builder.append("\n--- æ–‡ä»¶: ")
                    .append(file.getOriginalFilename())
                    .append(" ---\n")
                    .append(limitedText)
                    .append('\n');
            totalLength += limitedText.length();
        }
        return builder.toString();
    }
    private boolean containsImageFile(MultipartFile[] files) {
        for (MultipartFile file : files) {
            if (aiFileTextExtractor.isImageFile(file)) {
                return true;
            }
        }
        return false;
    }
    private List<String> resolveFileAccessPaths(List<StorageBlobVO> uploadedFiles) {
        if (StringUtils.isEmpty(uploadedFiles)) {
            return Collections.emptyList();
        }
        List<String> filePaths = new ArrayList<>();
        for (StorageBlobVO uploadedFile : uploadedFiles) {
            if (uploadedFile == null) {
                continue;
            }
            String selectedPath;
            if (shouldUsePreviewPath(uploadedFile)) {
                selectedPath = StringUtils.hasText(uploadedFile.getPreviewURL())
                        ? uploadedFile.getPreviewURL()
                        : uploadedFile.getDownloadURL();
            } else {
                selectedPath = StringUtils.hasText(uploadedFile.getDownloadURL())
                        ? uploadedFile.getDownloadURL()
                        : uploadedFile.getPreviewURL();
            }
            if (StringUtils.hasText(selectedPath)) {
                filePaths.add(selectedPath);
            }
        }
        return filePaths;
    }
    private boolean shouldUsePreviewPath(StorageBlobVO uploadedFile) {
        String contentType = uploadedFile.getContentType();
        if (StringUtils.hasText(contentType)) {
            String normalized = contentType.toLowerCase(Locale.ROOT);
            if (normalized.startsWith("image/") || "application/pdf".equals(normalized)) {
                return true;
            }
        }
        String filename = uploadedFile.getOriginalFilename();
        if (!StringUtils.hasText(filename) || !filename.contains(".")) {
            return false;
        }
        String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
        return StringUtils.inStringIgnoreCase(ext, "png", "jpg", "jpeg", "webp", "bmp", "pdf");
    }
    private List<MultipartFile> copyFilesForUpload(MultipartFile[] files) throws IOException {
        List<MultipartFile> copies = new ArrayList<>();
        for (MultipartFile file : files) {
            copies.add(new InMemoryMultipartFile(
                    file.getName(),
                    file.getOriginalFilename(),
                    file.getContentType(),
                    file.getBytes()
            ));
        }
        return copies;
    }
    private static final class InMemoryMultipartFile implements MultipartFile {
        private final String name;
        private final String originalFilename;
        private final String contentType;
        private final byte[] bytes;
        private InMemoryMultipartFile(String name, String originalFilename, String contentType, byte[] bytes) {
            this.name = name;
            this.originalFilename = originalFilename;
            this.contentType = contentType;
            this.bytes = bytes == null ? new byte[0] : bytes;
        }
        @Override
        public String getName() {
            return name;
        }
        @Override
        public String getOriginalFilename() {
            return originalFilename;
        }
        @Override
        public String getContentType() {
            return contentType;
        }
        @Override
        public boolean isEmpty() {
            return bytes.length == 0;
        }
        @Override
        public long getSize() {
            return bytes.length;
        }
        @Override
        public byte[] getBytes() {
            return bytes.clone();
        }
        @Override
        public InputStream getInputStream() {
            return new ByteArrayInputStream(bytes);
        }
        @Override
        public void transferTo(File dest) throws IOException, IllegalStateException {
            Files.write(dest.toPath(), bytes);
        }
    }
    private Flux<String> chatWithPurchaseVisionModel(String memoryId,
                                                     String userMessage,
                                                     String userPrompt,
                                                     MultipartFile[] files) {
        return Flux.create(sink -> {
            StringBuilder assistantReply = new StringBuilder();
            try {
                List<Content> contents = new ArrayList<>();
                contents.add(TextContent.from(userPrompt));
                for (MultipartFile file : files) {
                    if (!aiFileTextExtractor.isImageFile(file)) {
                        continue;
                    }
                    contents.add(TextContent.from("下面这张图片文件名:" + file.getOriginalFilename()));
                    contents.add(ImageContent.from(Image.builder()
                            .base64Data(Base64.getEncoder().encodeToString(file.getBytes()))
                            .mimeType(resolveImageMimeType(file))
                            .build()));
                }
                List<ChatMessage> messages = List.of(
                        SystemMessage.from("你是采购业务文件分析助手。请从文本和图片中识别采购台账、采购产品明细、付款或退货信息,只输出合法 JSON。"),
                        UserMessage.from(contents)
                );
                safeAppendMessages(memoryId, List.of(UserMessage.from("采购多文件分析: " + userMessage)));
                purchaseVisionStreamingChatModel.chat(messages, new StreamingChatResponseHandler() {
                    @Override
                    public void onPartialResponse(String partialResponse) {
                        if (partialResponse != null) {
                            assistantReply.append(partialResponse);
                            sink.next(partialResponse);
                        }
                    }
                    @Override
                    public void onCompleteResponse(ChatResponse completeResponse) {
                        if (StringUtils.hasText(assistantReply.toString())) {
                            safeAppendMessages(memoryId, List.of(AiMessage.from(assistantReply.toString())));
                        }
                        sink.complete();
                    }
                    @Override
                    public void onError(Throwable error) {
                        sink.error(error);
                    }
                });
            } catch (Exception ex) {
                sink.next("图片文件读取失败,请确认图片格式为 png、jpg、jpeg、webp æˆ– bmp,且大小不超过10MB");
                sink.complete();
            }
        });
    }
    private void safeAppendMessages(String memoryId, List<ChatMessage> messages) {
        if (!StringUtils.hasText(memoryId) || StringUtils.isEmpty(messages)) {
            return;
        }
        try {
            mongoChatMemoryStore.appendMessages(memoryId, messages);
        } catch (Exception ignored) {
        }
    }
    private String resolveImageMimeType(MultipartFile file) {
        String contentType = file.getContentType();
        if (StringUtils.hasText(contentType) && contentType.startsWith("image/")) {
            return contentType;
        }
        String filename = file.getOriginalFilename();
        String ext = "";
        if (StringUtils.hasText(filename) && filename.contains(".")) {
            ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        }
        return switch (ext) {
            case "jpg", "jpeg" -> "image/jpeg";
            case "webp" -> "image/webp";
            case "bmp" -> "image/bmp";
            default -> "image/png";
        };
    }
    private String buildPurchaseFileAnalyzePrompt(String message, String fileContent) {
        return """
                ä½ æ˜¯é‡‡è´­ä¸šåŠ¡æ–‡ä»¶åˆ†æžåŠ©æ‰‹ã€‚è¯·ä¸¥æ ¼æ ¹æ®ç”¨æˆ·ä¸Šä¼ çš„å¤šä¸ªæ–‡ä»¶å’Œç”¨æˆ·è¦æ±‚æå–é‡‡è´­ä¸šåŠ¡æ•°æ®ã€‚
                ç”¨æˆ·è¦æ±‚:
                %s
                è¾“出要求:
                1. åªè¾“出合法 JSON,不要 Markdown,不要额外解释。
                2. JSON é¡¶å±‚字段固定为:
                   - success: boolean
                   - businessType: purchase_ledger | payment_registration | purchase_return_order | unknown
                   - action: confirm_required
                   - description: ä¸­æ–‡è¯´æ˜Ž
                   - confidence: 0到1的小数
                   - missingFields: ç¼ºå¤±å­—段中文名称数组,面向客户展示,不要输出英文字段名
                   - warnings: é£Žé™©æç¤ºæ•°ç»„
                   - payload: å¾…客户确认的数据,字段名必须使用后端 DTO å­—段名
                   - preview: ç»™å®¢æˆ·ç¡®è®¤ç”¨çš„中文摘要数组
                3. å¦‚果可判断为采购台账,businessType ä½¿ç”¨ purchase_ledger,payload.purchaseLedgers ä¸ºé‡‡è´­è®¢å•/采购台账数组:
                   - purchaseLedgers: é‡‡è´­è®¢å•/采购台账数组,每条记录字段名必须与 PurchaseLedgerDto ä¿æŒä¸€è‡´
                   - äº§å“æ˜Žç»†å¿…须放在每条采购台账记录的 productData å­—段中,productData ç±»åž‹ä¸º List<SalesLedgerProduct>
                   - ä¸è¦ä¼˜å…ˆä½¿ç”¨ payload é¡¶å±‚ productData;顶层 productData ä»…作为旧格式兼容
                   - æ–‡ä»¶é‡Œçš„“采购单号”就是“采购合同号”,统一映射为 purchaseContractNumber
                   - æ–‡ä»¶é‡Œçš„“销售单号”就是“销售合同号”,统一映射为 salesContractNo
                   - æ‰€æœ‰æ—¥æœŸå­—段必须使用 yyyy-MM-dd,例如 2026-04-30;不要输出 4/30/26、2026/4/30、2026å¹´4月30日 æˆ–带时分秒的格式
                   - é‡‡è´­å°è´¦ä¸éœ€è¦åœ¨ payload ä¸­ä¼ å®¡æ‰¹äººï¼Œä¸è¦è¾“出 approveUserIds、approverId
                   - missingFields åªå¡«å†™ä¸šåŠ¡å¿…å¡«ä½†æ— æ³•è¯†åˆ«çš„å­—æ®µï¼Œä¸è¦æŠŠ PurchaseLedgerDto çš„æ‰€æœ‰ç©ºå­—段都列为缺失;缺失项必须写中文,例如“供应商名称”“含税单价”,不要写 supplierId、taxInclusiveUnitPrice
                   - é‡‡è´­å°è´¦ä¸»è¡¨å¿…填字段仅按这些判断: purchaseContractNumber、supplierName æˆ– supplierId
                   - productData æ¯æ¡äº§å“å¿…填字段: productCategory、specificationModel、unit、quantity、taxInclusiveUnitPrice æˆ– taxInclusiveTotalPrice;如果只有含税总价和数量,必须计算 taxInclusiveUnitPrice;如果只有含税单价和数量,必须计算 taxInclusiveTotalPrice
                   - äº§å“å­—段按采购导入接口 PurchaseLedgerProductImportDto å¯¹é½: é‡‡è´­å•号、产品大类、规格型号、单位、数量、税率、含税单价、含税总价、发票类型、是否质检
                   - é‡‡è´­äº§å“ type å›ºå®šä¸º 2
                   - purchaseLedgers æ¯æ¡è®°å½•只使用这些 PurchaseLedgerDto å­—段名:
                     entryDateStart, entryDateEnd, id, purchaseContractNumber, supplierId, supplierName, isWhite, recorderId, recorderName, salesContractNo, salesContractNoId, projectName, entryDate, executionDate, remarks, attachmentMaterials, createdAt, updatedAt, salesLedgerId, hasChildren, Type, productData, tempFileIds, SalesLedgerFiles, phoneNumber, businessPersonId, productId, productModelId, invoiceNumber, invoiceAmount, ticketRegistrationId, contractAmount, receiptPaymentAmount, unReceiptPaymentAmount, type, paymentMethod, approvalStatus, templateName
                   - productData æ¯æ¡äº§å“åªä½¿ç”¨è¿™äº› SalesLedgerProduct å­—段名:
                     productCategory, specificationModel, unit, quantity, taxRate, taxInclusiveUnitPrice, taxInclusiveTotalPrice, taxExclusiveTotalPrice, invoiceType, productId, productModelId, isChecked, type
                4. å¦‚果可判断为付款登记,businessType ä½¿ç”¨ payment_registration,payload.records ä¸ºä»˜æ¬¾ç™»è®°æ•°ç»„,字段尽量包含 purchaseLedgerId、salesLedgerProductId、currentPaymentAmount、paymentMethod、paymentDate。
                5. å¦‚果可判断为采购退货,businessType ä½¿ç”¨ purchase_return_order,payload æŒ‰ PurchaseReturnOrderDto ç»„织,明细放 purchaseReturnOrderProductsDtos。
                6. ç¼ºå°‘业务处理必须字段时,不要编造 ID,把字段放入 missingFields,并仍返回可确认的草稿数据。
                7. æ‰€æœ‰ä¸­æ–‡å†…容直接保留,不要转义成 Unicode。
                æ–‡ä»¶å†…容:
                %s
                """.formatted(message, fileContent);
    }
    private AjaxResult processPurchaseLedger(Map<String, Object> payload) throws Exception {
        if (payload.containsKey("purchaseLedgers")) {
            return processPurchaseLedgerBatch(payload);
        }
        Map<String, Object> normalizedPayload = normalizePurchaseLedgerMap(payload);
        PurchaseLedgerDto dto = objectMapper.convertValue(normalizedPayload, PurchaseLedgerDto.class);
        AjaxResult ledgerResult = validatePurchaseLedger(dto, 0);
        if (ledgerResult != null) {
            return ledgerResult;
        }
        AjaxResult supplierResult = fillSupplierIdByName(dto);
        if (supplierResult != null) {
            return supplierResult;
        }
        AjaxResult productResult = validatePurchaseProducts(dto.getProductData(), 0);
        if (productResult != null) {
            return productResult;
        }
        int result = purchaseLedgerService.addOrEditPurchase(dto);
        return AjaxResult.success("采购台账已处理", result);
    }
    private AjaxResult processPurchaseLedgerBatch(Map<String, Object> payload) throws Exception {
        List<Map<String, Object>> purchaseLedgers = toMapList(payload.get("purchaseLedgers"));
        if (purchaseLedgers.isEmpty()) {
            return AjaxResult.error("purchaseLedgers不能为空");
        }
        List<Map<String, Object>> topLevelProductData = toMapList(payload.get("productData"));
        List<Map<String, Object>> results = new ArrayList<>();
        for (int i = 0; i < purchaseLedgers.size(); i++) {
            Map<String, Object> ledgerMap = normalizePurchaseLedgerMap(purchaseLedgers.get(i));
            PurchaseLedgerDto dto = objectMapper.convertValue(ledgerMap, PurchaseLedgerDto.class);
            AjaxResult ledgerResult = validatePurchaseLedger(dto, i);
            if (ledgerResult != null) {
                return ledgerResult;
            }
            AjaxResult supplierResult = fillSupplierIdByName(dto);
            if (supplierResult != null) {
                return supplierResult;
            }
            List<SalesLedgerProduct> products = dto.getProductData();
            if (products == null || products.isEmpty()) {
                products = matchProductsForLedger(ledgerMap, dto, topLevelProductData, purchaseLedgers.size() == 1);
                dto.setProductData(products);
            }
            AjaxResult productResult = validatePurchaseProducts(products, i);
            if (productResult != null) {
                return productResult;
            }
            int result = purchaseLedgerService.addOrEditPurchase(dto);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("index", i);
            item.put("purchaseContractNumber", dto.getPurchaseContractNumber());
            item.put("supplierId", dto.getSupplierId());
            item.put("supplierName", dto.getSupplierName());
            item.put("productCount", products.size());
            item.put("result", result);
            results.add(item);
        }
        return AjaxResult.success("采购台账已批量处理", results);
    }
    private List<SalesLedgerProduct> matchProductsForLedger(Map<String, Object> ledgerMap,
                                                            PurchaseLedgerDto dto,
                                                            List<Map<String, Object>> productData,
                                                            boolean onlyOneLedger) {
        List<SalesLedgerProduct> products = new ArrayList<>();
        for (Map<String, Object> productMap : productData) {
            if (onlyOneLedger || productBelongsToLedger(productMap, ledgerMap, dto)) {
                products.add(objectMapper.convertValue(normalizeSalesLedgerProductMap(productMap), SalesLedgerProduct.class));
            }
        }
        return products;
    }
    private boolean productBelongsToLedger(Map<String, Object> productMap, Map<String, Object> ledgerMap, PurchaseLedgerDto dto) {
        Long productPurchaseLedgerId = longValue(productMap, "purchaseLedgerId", "purchaseId", "采购订单id", "采购台账id");
        if (productPurchaseLedgerId != null && dto.getId() != null && productPurchaseLedgerId.equals(dto.getId())) {
            return true;
        }
        Long productSalesLedgerId = longValue(productMap, "salesLedgerId");
        if (productSalesLedgerId != null && dto.getId() != null && productSalesLedgerId.equals(dto.getId())) {
            return true;
        }
        String productContractNo = stringValue(productMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(dto.getPurchaseContractNumber())
                && productContractNo.trim().equals(dto.getPurchaseContractNumber().trim())) {
            return true;
        }
        String ledgerContractNo = stringValue(ledgerMap, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        if (StringUtils.hasText(productContractNo)
                && StringUtils.hasText(ledgerContractNo)
                && productContractNo.trim().equals(ledgerContractNo.trim())) {
            return true;
        }
        String productSalesContractNo = stringValue(productMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(dto.getSalesContractNo())
                && productSalesContractNo.trim().equals(dto.getSalesContractNo().trim())) {
            return true;
        }
        String ledgerSalesContractNo = stringValue(ledgerMap, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        if (StringUtils.hasText(productSalesContractNo)
                && StringUtils.hasText(ledgerSalesContractNo)
                && productSalesContractNo.trim().equals(ledgerSalesContractNo.trim())) {
            return true;
        }
        String productSupplierName = stringValue(productMap, "supplierName", "供应商名称");
        return StringUtils.hasText(productSupplierName)
                && StringUtils.hasText(dto.getSupplierName())
                && productSupplierName.trim().equals(dto.getSupplierName().trim());
    }
    private Map<String, Object> normalizePurchaseLedgerMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copyPurchaseLedgerDtoFields(source, target);
        putDtoFieldIfPresent(source, target, "entryDateStart", "录入开始日期", "录入日期开始");
        putDtoFieldIfPresent(source, target, "entryDateEnd", "录入结束日期", "录入日期结束");
        putDtoFieldIfPresent(source, target, "id", "采购台账id", "采购订单id", "主键");
        putDtoFieldIfPresent(source, target, "purchaseContractNumber", "purchaseContractNo", "采购合同号", "采购单号", "采购订单号");
        putDtoFieldIfPresent(source, target, "supplierId", "供应商id", "供应商ID", "供应商名称id", "供应商名称ID");
        putDtoFieldIfPresent(source, target, "supplierName", "供应商", "供应商名称");
        putDtoFieldIfPresent(source, target, "isWhite", "是否白名单");
        putDtoFieldIfPresent(source, target, "recorderId", "录入人id", "录入人ID", "录入人姓名id", "录入人姓名ID");
        putDtoFieldIfPresent(source, target, "recorderName", "录入人", "录入人姓名");
        putDtoFieldIfPresent(source, target, "salesContractNo", "salesContractNumber", "销售合同号", "销售单号", "销售订单号");
        putDtoFieldIfPresent(source, target, "salesContractNoId", "销售合同号id", "销售合同号ID", "销售单号id", "销售单号ID");
        putDtoFieldIfPresent(source, target, "projectName", "项目", "项目名称");
        putDtoFieldIfPresent(source, target, "entryDate", "录入日期");
        putDtoFieldIfPresent(source, target, "executionDate", "签订日期", "合同签订日期");
        putDtoFieldIfPresent(source, target, "remarks", "备注", "说明");
        putDtoFieldIfPresent(source, target, "attachmentMaterials", "附件材料", "附件材料路径或名称");
        putDtoFieldIfPresent(source, target, "createdAt", "创建时间", "记录创建时间");
        putDtoFieldIfPresent(source, target, "updatedAt", "更新时间", "记录最后更新时间");
        putDtoFieldIfPresent(source, target, "salesLedgerId", "销售台账id", "销售台账ID", "关联销售台账主表主键");
        putDtoFieldIfPresent(source, target, "hasChildren", "是否有子级", "是否有明细");
        putDtoFieldIfPresent(source, target, "Type", "台账类型", "业务类型");
        putDtoFieldIfPresent(source, target, "productData", "products", "产品明细", "采购产品明细");
        putDtoFieldIfPresent(source, target, "tempFileIds", "临时文件id", "临时文件ID", "临时文件ids");
        putDtoFieldIfPresent(source, target, "SalesLedgerFiles", "附件列表", "销售台账附件");
        putDtoFieldIfPresent(source, target, "phoneNumber", "业务员手机号", "手机号");
        putDtoFieldIfPresent(source, target, "businessPersonId", "业务员id", "业务员ID");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID");
        putDtoFieldIfPresent(source, target, "invoiceNumber", "发票号", "发票号码");
        putDtoFieldIfPresent(source, target, "invoiceAmount", "发票金额", "发票金额(元)");
        putDtoFieldIfPresent(source, target, "ticketRegistrationId", "来票登记id", "来票登记ID");
        putDtoFieldIfPresent(source, target, "contractAmount", "合同金额", "合同金额(产品含税总价)");
        putDtoFieldIfPresent(source, target, "receiptPaymentAmount", "来票金额", "已来票金额", "已来票金额(元)");
        putDtoFieldIfPresent(source, target, "unReceiptPaymentAmount", "未来票金额", "未来票金额(元)");
        putDtoFieldIfPresent(source, target, "type", "文件类型");
        putDtoFieldIfPresent(source, target, "paymentMethod", "付款方式");
        putDtoFieldIfPresent(source, target, "approvalStatus", "审批状态");
        putDtoFieldIfPresent(source, target, "templateName", "模板名称");
        target.remove("approveUserIds");
        target.remove("approverId");
        normalizeNestedProductData(target);
        attachImportStyleProductData(source, target);
        if (target.get("type") == null) {
            target.put("type", 2);
        }
        target.putIfAbsent("entryDate", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
        normalizePurchaseLedgerDateFields(target);
        return target;
    }
    private void attachImportStyleProductData(Map<String, Object> source, Map<String, Object> target) {
        if (target.get("productData") != null) {
            return;
        }
        Map<String, Object> productMap = normalizeSalesLedgerProductMap(source);
        if (hasImportStyleProductData(productMap)) {
            target.put("productData", List.of(productMap));
        }
    }
    private boolean hasImportStyleProductData(Map<String, Object> productMap) {
        return hasMapText(productMap, "productCategory")
                || hasMapText(productMap, "specificationModel")
                || productMap.get("quantity") != null
                || productMap.get("taxInclusiveUnitPrice") != null
                || productMap.get("taxInclusiveTotalPrice") != null;
    }
    private boolean hasMapText(Map<String, Object> map, String key) {
        Object value = map.get(key);
        return value != null && StringUtils.hasText(String.valueOf(value));
    }
    private void normalizeNestedProductData(Map<String, Object> target) {
        Object productDataValue = target.get("productData");
        if (productDataValue == null) {
            return;
        }
        List<Map<String, Object>> productMaps = toMapList(productDataValue);
        List<Map<String, Object>> normalizedProducts = new ArrayList<>();
        for (Map<String, Object> productMap : productMaps) {
            normalizedProducts.add(normalizeSalesLedgerProductMap(productMap));
        }
        target.put("productData", normalizedProducts);
    }
    private Map<String, Object> normalizeSalesLedgerProductMap(Map<String, Object> source) {
        Map<String, Object> target = new LinkedHashMap<>();
        copySalesLedgerProductFields(source, target);
        putDtoFieldIfPresent(source, target, "productCategory", "产品大类", "产品名称", "产品", "品名", "物料名称");
        putDtoFieldIfPresent(source, target, "specificationModel", "规格型号", "型号", "规格", "产品规格");
        putDtoFieldIfPresent(source, target, "unit", "单位");
        putDtoFieldIfPresent(source, target, "quantity", "数量", "采购数量");
        putDtoFieldIfPresent(source, target, "taxRate", "税率");
        putDtoFieldIfPresent(source, target, "taxInclusiveUnitPrice", "含税单价", "单价", "采购单价", "含税价格");
        putDtoFieldIfPresent(source, target, "taxInclusiveTotalPrice", "含税总价", "总价", "采购金额", "金额", "合同金额");
        putDtoFieldIfPresent(source, target, "taxExclusiveTotalPrice", "不含税总价");
        putDtoFieldIfPresent(source, target, "invoiceType", "发票类型", "发票类别");
        putDtoFieldIfPresent(source, target, "productId", "产品id", "产品ID");
        putDtoFieldIfPresent(source, target, "productModelId", "产品规格id", "产品规格ID", "型号id", "型号ID");
        putDtoFieldIfPresent(source, target, "isChecked", "是否质检", "是否质检验", "质检");
        putDtoFieldIfPresent(source, target, "type", "台账类型");
        normalizeProductAmounts(target);
        target.putIfAbsent("type", 2);
        return target;
    }
    private void copySalesLedgerProductFields(Map<String, Object> source, Map<String, Object> target) {
        String[] productFields = {
                "id", "salesLedgerId", "warnNum", "productCategory", "specificationModel", "unit",
                "speculativeTradingName", "quantity", "minStock", "taxRate", "taxInclusiveUnitPrice",
                "taxInclusiveTotalPrice", "taxExclusiveTotalPrice", "invoiceType", "type", "ticketsNum",
                "ticketsAmount", "futureTickets", "futureTicketsAmount", "invoiceNum", "noInvoiceNum",
                "invoiceAmount", "noInvoiceAmount", "productId", "productModelId", "register", "registerDate",
                "approveStatus", "pendingInvoiceTotal", "invoiceTotal", "pendingTicketsTotal", "ticketsTotal",
                "isChecked", "isProduction"
        };
        for (String field : productFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void normalizeProductAmounts(Map<String, Object> target) {
        BigDecimal quantity = decimalValue(target.get("quantity"));
        BigDecimal unitPrice = decimalValue(target.get("taxInclusiveUnitPrice"));
        BigDecimal totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (unitPrice == null && totalPrice != null && quantity != null && quantity.compareTo(BigDecimal.ZERO) != 0) {
            target.put("taxInclusiveUnitPrice", totalPrice.divide(quantity, 6, RoundingMode.HALF_UP));
        }
        if (totalPrice == null && unitPrice != null && quantity != null) {
            target.put("taxInclusiveTotalPrice", unitPrice.multiply(quantity));
        }
        BigDecimal taxRate = decimalValue(target.get("taxRate"));
        totalPrice = decimalValue(target.get("taxInclusiveTotalPrice"));
        if (target.get("taxExclusiveTotalPrice") == null && totalPrice != null && taxRate != null) {
            BigDecimal divisor = BigDecimal.ONE.add(taxRate.divide(new BigDecimal("100"), 6, RoundingMode.HALF_UP));
            target.put("taxExclusiveTotalPrice", totalPrice.divide(divisor, 2, RoundingMode.HALF_UP));
        }
    }
    private AjaxResult validatePurchaseProducts(List<SalesLedgerProduct> products, int ledgerIndex) {
        if (products == null || products.isEmpty()) {
            return null;
        }
        for (int i = 0; i < products.size(); i++) {
            SalesLedgerProduct product = products.get(i);
            String prefix = "第" + (ledgerIndex + 1) + "个采购台账的第" + (i + 1) + "条产品";
            if (!StringUtils.hasText(product.getProductCategory())) {
                return AjaxResult.error(prefix + "缺少产品名称,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getSpecificationModel())) {
                return AjaxResult.error(prefix + "缺少规格型号,请补充后再确认");
            }
            if (!StringUtils.hasText(product.getUnit())) {
                return AjaxResult.error(prefix + "缺少单位,请补充后再确认");
            }
            if (product.getQuantity() == null) {
                return AjaxResult.error(prefix + "缺少数量");
            }
            if (product.getTaxInclusiveUnitPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税单价,请补充后再确认");
            }
            if (product.getTaxInclusiveTotalPrice() == null) {
                return AjaxResult.error(prefix + "缺少含税总价,请补充后再确认");
            }
        }
        return null;
    }
    private AjaxResult validatePurchaseLedger(PurchaseLedgerDto dto, int ledgerIndex) {
        String prefix = "第" + (ledgerIndex + 1) + "个采购台账";
        if (!StringUtils.hasText(dto.getPurchaseContractNumber())) {
            return AjaxResult.error(prefix + "缺少采购合同号,请补充后再确认");
        }
        if (dto.getSupplierId() == null && !StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error(prefix + "缺少供应商名称,请补充后再确认");
        }
        return null;
    }
    private void normalizePurchaseLedgerDateFields(Map<String, Object> target) {
        normalizeDateField(target, "entryDate");
        normalizeDateField(target, "executionDate");
        normalizeDateField(target, "createdAt");
        normalizeDateField(target, "updatedAt");
    }
    private void normalizeDateField(Map<String, Object> target, String fieldName) {
        Object value = target.get(fieldName);
        if (value == null) {
            return;
        }
        String normalizedDate = normalizeDateValue(value);
        if (StringUtils.hasText(normalizedDate)) {
            target.put(fieldName, normalizedDate);
        }
    }
    private String normalizeDateValue(Object value) {
        if (value instanceof Date date) {
            return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        if (value instanceof Number number) {
            return LocalDate.of(1899, 12, 30)
                    .plusDays(number.longValue())
                    .format(DateTimeFormatter.ISO_LOCAL_DATE);
        }
        String text = String.valueOf(value).trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        if (text.length() >= 10 && text.charAt(4) == '-' && text.charAt(7) == '-') {
            return text.substring(0, 10);
        }
        String normalizedText = text.replace("å¹´", "-")
                .replace("月", "-")
                .replace("日", "")
                .replace(".", "-")
                .replace("/", "-")
                .trim();
        DateTimeFormatter[] formatters = {
                DateTimeFormatter.ofPattern("yyyy-M-d"),
                DateTimeFormatter.ofPattern("M-d-yyyy"),
                DateTimeFormatter.ofPattern("M-d-yy")
        };
        for (DateTimeFormatter formatter : formatters) {
            try {
                return LocalDate.parse(normalizedText, formatter).format(DateTimeFormatter.ISO_LOCAL_DATE);
            } catch (DateTimeParseException ignored) {
                // Try the next supported input pattern.
            }
        }
        return text;
    }
    private void copyPurchaseLedgerDtoFields(Map<String, Object> source, Map<String, Object> target) {
        String[] dtoFields = {
                "entryDateStart", "entryDateEnd", "id", "purchaseContractNumber",
                "supplierId", "supplierName", "isWhite", "recorderId", "recorderName", "salesContractNo",
                "salesContractNoId", "projectName", "entryDate", "executionDate", "remarks", "attachmentMaterials",
                "createdAt", "updatedAt", "salesLedgerId", "hasChildren", "Type", "productData", "tempFileIds",
                "SalesLedgerFiles", "phoneNumber", "businessPersonId", "productId", "productModelId", "invoiceNumber",
                "invoiceAmount", "ticketRegistrationId", "contractAmount", "receiptPaymentAmount",
                "unReceiptPaymentAmount", "type", "paymentMethod", "approvalStatus", "templateName"
        };
        for (String field : dtoFields) {
            if (source.containsKey(field)) {
                target.put(field, source.get(field));
            }
        }
    }
    private void putDtoFieldIfPresent(Map<String, Object> source, Map<String, Object> target, String dtoField, String... aliases) {
        if (target.containsKey(dtoField) && target.get(dtoField) != null) {
            return;
        }
        for (String alias : aliases) {
            Object value = source.get(alias);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                target.put(dtoField, value);
                return;
            }
        }
    }
    private List<Map<String, Object>> toMapList(Object value) {
        if (value == null) {
            return List.of();
        }
        return objectMapper.convertValue(value, new TypeReference<List<Map<String, Object>>>() {
        });
    }
    private String stringValue(Map<String, Object> map, String... keys) {
        for (String key : keys) {
            Object value = map.get(key);
            if (value != null && StringUtils.hasText(String.valueOf(value))) {
                return String.valueOf(value);
            }
        }
        return null;
    }
    private Long longValue(Map<String, Object> map, String... keys) {
        String value = stringValue(map, keys);
        if (!StringUtils.hasText(value)) {
            return null;
        }
        try {
            return Long.parseLong(value.trim());
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof BigDecimal decimal) {
            return decimal;
        }
        if (value instanceof Number number) {
            return new BigDecimal(String.valueOf(number));
        }
        String text = String.valueOf(value)
                .replace(",", "")
                .replace(",", "")
                .replace("元", "")
                .replace("ï¿¥", "")
                .trim();
        if (!StringUtils.hasText(text)) {
            return null;
        }
        try {
            return new BigDecimal(text);
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private String toCustomerMessage(Exception ex) {
        String message = ex.getMessage();
        if (!StringUtils.hasText(message)) {
            return "处理失败,请检查确认数据后重试";
        }
        if (message.contains("tax_inclusive_unit_price")) {
            return "处理失败:产品明细缺少含税单价,请补充后再确认";
        }
        if (message.contains("tax_inclusive_total_price")) {
            return "处理失败:产品明细缺少含税总价,请补充后再确认";
        }
        if (message.contains("entryDate")) {
            return "处理失败:录入日期格式不正确,请使用 yyyy-MM-dd,例如 2026-04-30";
        }
        if (message.contains("supplier")) {
            return "处理失败:供应商信息不完整,请确认供应商名称或供应商ID";
        }
        if (message.contains("SQL") || message.contains("java.") || message.contains("Exception")) {
            return "处理失败:确认数据不完整或格式不正确,请检查必填字段后重试";
        }
        return "处理失败:" + message;
    }
    private AjaxResult fillSupplierIdByName(PurchaseLedgerDto dto) {
        if (dto.getSupplierId() != null) {
            return null;
        }
        if (!StringUtils.hasText(dto.getSupplierName())) {
            return AjaxResult.error("供应商ID不能为空;未识别到供应商名称,无法自动匹配供应商ID");
        }
        SupplierManage supplier = supplierManageMapper.selectOne(new LambdaQueryWrapper<SupplierManage>()
                .eq(SupplierManage::getSupplierName, dto.getSupplierName().trim())
                .last("limit 1"));
        if (supplier == null) {
            return AjaxResult.error("未找到供应商:" + dto.getSupplierName() + ",请先维护供应商或手动选择供应商ID");
        }
        dto.setSupplierId(supplier.getId());
        return null;
    }
    private AjaxResult processPaymentRegistration(Map<String, Object> payload) {
        Object recordsValue = payload.get("records");
        List<PaymentRegistration> records;
        if (recordsValue == null) {
            records = Collections.singletonList(objectMapper.convertValue(payload, PaymentRegistration.class));
        } else {
            records = objectMapper.convertValue(recordsValue, new TypeReference<List<PaymentRegistration>>() {
            });
        }
        int result = paymentRegistrationService.insertPaymentRegistration(records);
        return AjaxResult.success("付款登记已处理", result);
    }
    private AjaxResult processPurchaseReturnOrder(Map<String, Object> payload) {
        PurchaseReturnOrderDto dto = objectMapper.convertValue(payload, PurchaseReturnOrderDto.class);
        Boolean result = purchaseReturnOrdersService.add(dto);
        return AjaxResult.success("采购退货单已处理", result);
    }
}
src/main/java/com/ruoyi/ai/service/impl/AiChatSessionServiceImpl.java
@@ -114,7 +114,10 @@
            return new LinkedList<>();
        }
        List<ChatMessage> messages = mongoChatMemoryStore.getMessages(memoryId);
        return messages.stream().map(this::convertMessage).collect(Collectors.toList());
        List<AiChatMessageDto> messageDtos = messages.stream().map(this::convertMessage).collect(Collectors.toList());
        List<List<String>> analyzeFilePathGroups = mongoChatMemoryStore.getAnalyzeFilePathGroups(memoryId);
        attachAnalyzeFilePaths(messageDtos, analyzeFilePathGroups);
        return messageDtos;
    }
    @Override
@@ -188,4 +191,22 @@
        }
        return new AiChatMessageDto("unknown", String.valueOf(message));
    }
    private void attachAnalyzeFilePaths(List<AiChatMessageDto> messages,
                                        List<List<String>> analyzeFilePathGroups) {
        if (StringUtils.isEmpty(messages) || StringUtils.isEmpty(analyzeFilePathGroups)) {
            return;
        }
        int analyzeIndex = 0;
        for (AiChatMessageDto message : messages) {
            if (!"user".equals(message.getRole()) || analyzeIndex >= analyzeFilePathGroups.size()) {
                continue;
            }
            List<String> filePaths = analyzeFilePathGroups.get(analyzeIndex);
            if (!StringUtils.isEmpty(filePaths)) {
                message.setFilePaths(filePaths);
            }
            analyzeIndex++;
        }
    }
}
src/main/java/com/ruoyi/ai/store/MongoChatMemoryStore.java
@@ -11,6 +11,8 @@
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.LinkedList;
@@ -24,8 +26,7 @@
    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdString(memoryId)));
        ChatMessages chatMessages = mongoTemplate.findOne(query, ChatMessages.class);
        ChatMessages chatMessages = findChatMessages(memoryId);
        if (chatMessages == null || chatMessages.getContent() == null) {
            return new LinkedList<>();
        }
@@ -56,7 +57,75 @@
        updateMessages(memoryId, messages);
    }
    public void appendAnalyzeFileContext(Object memoryId, String userQuestion, List<String> filePaths) {
        String memoryIdValue = memoryIdString(memoryId);
        if (!StringUtils.hasText(memoryIdValue)) {
            return;
        }
        List<String> validFilePaths = new LinkedList<>();
        if (!CollectionUtils.isEmpty(filePaths)) {
            for (String filePath : filePaths) {
                if (StringUtils.hasText(filePath)) {
                    validFilePaths.add(filePath);
                }
            }
        }
        if (!StringUtils.hasText(userQuestion) && validFilePaths.isEmpty()) {
            return;
        }
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdValue));
        Update update = new Update();
        update.set("memoryId", memoryIdValue);
        update.set("updateTime", new Date());
        update.setOnInsert("createTime", new Date());
        if (StringUtils.hasText(userQuestion)) {
            update.push("analyzeUserQuestions", userQuestion);
        }
        if (!validFilePaths.isEmpty()) {
            update.push("analyzeFilePaths").each(validFilePaths.toArray());
            update.push("analyzeFilePathGroups", validFilePaths);
        }
        mongoTemplate.upsert(query, update, ChatMessages.class);
    }
    public List<String> getAnalyzeUserQuestions(Object memoryId) {
        ChatMessages chatMessages = findChatMessages(memoryId);
        if (chatMessages == null || CollectionUtils.isEmpty(chatMessages.getAnalyzeUserQuestions())) {
            return new LinkedList<>();
        }
        return new LinkedList<>(chatMessages.getAnalyzeUserQuestions());
    }
    public List<List<String>> getAnalyzeFilePathGroups(Object memoryId) {
        ChatMessages chatMessages = findChatMessages(memoryId);
        if (chatMessages == null) {
            return new LinkedList<>();
        }
        if (CollectionUtils.isEmpty(chatMessages.getAnalyzeFilePathGroups())) {
            if (CollectionUtils.isEmpty(chatMessages.getAnalyzeFilePaths())) {
                return new LinkedList<>();
            }
            List<List<String>> fallback = new LinkedList<>();
            fallback.add(new LinkedList<>(chatMessages.getAnalyzeFilePaths()));
            return fallback;
        }
        List<List<String>> groups = new LinkedList<>();
        for (List<String> group : chatMessages.getAnalyzeFilePathGroups()) {
            if (CollectionUtils.isEmpty(group)) {
                groups.add(new LinkedList<>());
            } else {
                groups.add(new LinkedList<>(group));
            }
        }
        return groups;
    }
    private String memoryIdString(Object memoryId) {
        return memoryId == null ? "" : memoryId.toString();
    }
    private ChatMessages findChatMessages(Object memoryId) {
        Query query = Query.query(Criteria.where("memoryId").is(memoryIdString(memoryId)));
        return mongoTemplate.findOne(query, ChatMessages.class);
    }
}
src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java
@@ -183,7 +183,7 @@
                            addQualityInspect(purchaseLedger, salesLedgerProduct);
                        } else {
                            //直接入库
                            stockUtils.addStock(salesLedgerProduct.getProductModelId(), salesLedgerProduct.getQuantity(), StockInQualifiedRecordTypeEnum.PURCHASE_STOCK_IN.getCode(), purchaseLedger.getId());
                            stockUtils.addStockWithBatchNo(salesLedgerProduct.getProductModelId(), salesLedgerProduct.getQuantity(), StockInQualifiedRecordTypeEnum.PURCHASE_STOCK_IN.getCode(), purchaseLedger.getId(),purchaseLedger.getPurchaseContractNumber()+"-"+salesLedgerProduct.getId());
                        }
                    }
                } else if (status.equals(3)) {
@@ -211,11 +211,10 @@
            }
            salesQuotationMapper.updateById(salesQuote);
        }
        // å‡ºåº“审批修改
        // å‡ºåº“审批修改=发货审批
        if (approveProcess.getApproveType().equals(7)) {
            String[] split = approveProcess.getApproveReason().split(":");
            ShippingInfo shippingInfo = shippingInfoMapper.selectOne(new LambdaQueryWrapper<ShippingInfo>()
                    .eq(ShippingInfo::getShippingNo, split[1])
                    .eq(ShippingInfo::getShippingNo, approveProcess.getApproveReason())
                    .orderByDesc(ShippingInfo::getCreateTime)
                    .last("limit 1"));
            if (shippingInfo != null) {
@@ -228,6 +227,7 @@
                }
                shippingInfoMapper.updateById(shippingInfo);
            }
            //库存扣减
        }
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.APPROVE_NODE, approveNode.getId(), approveNode.getStorageBlobDTOS());
src/main/java/com/ruoyi/approve/service/impl/ApproveProcessServiceImpl.java
@@ -1,6 +1,7 @@
package com.ruoyi.approve.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -24,8 +25,10 @@
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.enums.FileNameType;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.utils.OrderUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.project.system.domain.SysDept;
import com.ruoyi.project.system.domain.SysNotice;
import com.ruoyi.project.system.domain.SysUser;
@@ -35,8 +38,10 @@
import com.ruoyi.purchase.mapper.PurchaseLedgerMapper;
import com.ruoyi.purchase.pojo.PurchaseLedger;
import com.ruoyi.sales.mapper.CommonFileMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.pojo.CommonFile;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.ShippingInfo;
import com.ruoyi.sales.service.impl.CommonFileServiceImpl;
import lombok.RequiredArgsConstructor;
@@ -65,6 +70,8 @@
    private final CommonFileServiceImpl commonFileService;
    private final ISysNoticeService sysNoticeService;
    private final PurchaseLedgerMapper purchaseLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final StockUtils stockUtils;
    private final ShippingInfoMapper shippingInfoMapper;
    private final ApproveNodeMapper approveNodeMapper;
    private final ApproveProcessConfigNodeService approveProcessConfigNodeService;
@@ -80,22 +87,15 @@
                .map(ApproveProcessConfigNodeVo::getApproverId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if (list.isEmpty()) {
            throw new RuntimeException("流程不存在");
        }
        // æ— å®¡æ ¸äººé€»è¾‘添加
        if (CollectionUtils.isEmpty(nodeIds)) {
            autoPassPurchaseApproveIfNoApprover(approveProcessVO);
            autoPassPurchaseApproveIfNoApprover(approveProcessVO); // é‡‡è´­å•无审核人逻辑
            return;
        }
        List<SysUser> sysUsers = sysUserMapper.selectUserByIds(nodeIds);
        if (CollectionUtils.isEmpty(sysUsers)) throw new RuntimeException("审核用户不存在");
        if (sysDept == null) throw new RuntimeException("部门不存在");
        if (sysUser == null) throw new RuntimeException("申请人不存在");
//        String today = LocalDate.now().format(DATE_FORMAT);
//        Long approveId = dailyRedisCounter.incrementAndGetByDb();
//        String formattedCount = String.format("%03d", approveId);
//        //流程 ID
//        String approveID = today + formattedCount;
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        ApproveProcess approveProcess = new ApproveProcess();
        String no = OrderUtils.countTodayByCreateTime(approveProcessMapper, "", "approve_id");
@@ -159,9 +159,19 @@
                || !StringUtils.hasText(approveProcessVO.getApproveReason())) {
            throw new RuntimeException("审核用户不存在");
        }
        purchaseLedgerMapper.update(null, new LambdaUpdateWrapper<PurchaseLedger>()
                .eq(PurchaseLedger::getPurchaseContractNumber, approveProcessVO.getApproveReason())
                .set(PurchaseLedger::getApprovalStatus, 3));
        //采购入库
        PurchaseLedger purchaseLedger = purchaseLedgerMapper.selectOne(new LambdaQueryWrapper<PurchaseLedger>()
                .eq(PurchaseLedger::getPurchaseContractNumber, approveProcessVO.getApproveReason())
                .last("limit 1"));
        List<SalesLedgerProduct> salesLedgerProducts = salesLedgerProductMapper.selectList(new QueryWrapper<SalesLedgerProduct>()
                .lambda().eq(SalesLedgerProduct::getSalesLedgerId, purchaseLedger.getId()).eq(SalesLedgerProduct::getType, 2));
        for (SalesLedgerProduct salesLedgerProduct : salesLedgerProducts) {
            stockUtils.addStockWithBatchNo(salesLedgerProduct.getProductModelId(), salesLedgerProduct.getQuantity(), StockInQualifiedRecordTypeEnum.PURCHASE_STOCK_IN.getCode(), purchaseLedger.getId(),purchaseLedger.getPurchaseContractNumber()+"-"+salesLedgerProduct.getId());
        }
    }
    @Override
src/main/java/com/ruoyi/basic/dto/ProductModelExportDto.java
@@ -15,6 +15,9 @@
@Data
public class ProductModelExportDto {
    @Excel(name = "产品编码")
    private String productCode;
    @Excel(name = "规格型号")
    private String model;
src/main/java/com/ruoyi/basic/service/impl/ProductModelServiceImpl.java
@@ -121,6 +121,9 @@
                ProductModel item = productModelList.get(i);
                int rowNum = i + 2;
                if (StringUtils.isEmpty(item.getProductCode())) {
                    return AjaxResult.error("第 " + rowNum + " è¡Œå¯¼å…¥å¤±è´¥: [产品编码] ä¸èƒ½ä¸ºç©º");
                }
                if (StringUtils.isEmpty(item.getModel())) {
                    return AjaxResult.error("第 " + rowNum + " è¡Œå¯¼å…¥å¤±è´¥: [规格型号] ä¸èƒ½ä¸ºç©º");
                }
src/main/java/com/ruoyi/common/enums/StockInQualifiedRecordTypeEnum.java
@@ -23,8 +23,9 @@
    SALE_SHIP_STOCK_OUT("13", "销售-发货出库"),
    RETURN_HE_IN("14", "销售退货-合格入库"),
    RETURN_UNSTOCK_IN("15", "销售退货-不合格入库"),
    PICK_RETURN_IN("20", "销售退货-合格入库"),
    PURCHASE_RETURN_STOCK_OUT("21", "采购退货");
    PICK_RETURN_IN("20", "领料退料-合格入库"),
    PURCHASE_RETURN_STOCK_OUT("21", "采购退货"),
    FEED_RETURN_IN("22", "生产退料-合格入库");
src/main/java/com/ruoyi/common/enums/StockOutQualifiedRecordTypeEnum.java
@@ -9,7 +9,9 @@
    PRODUCTION_REPORT_STOCK_OUT("3", "生产报工-出库"),
    SALE_STOCK_OUT("8", "销售-出库"),
    PURCHASE_RETURN_STOCK_OUT("9", "采购退货"),
    SALE_SHIP_STOCK_OUT("13", "销售-发货出库");
    SALE_SHIP_STOCK_OUT("13", "销售-发货出库"),
    PICK_STOCK_OUT("14", "生产领料出库"),
    FEED_STOCK_OUT("15", "生产补料出库");
    private final String code;
    private final String value;
src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java
@@ -476,6 +476,22 @@
                            val = DateUtil.getJavaDate((Double) val);
                        }
                    }
                    else if (LocalDate.class == fieldType)
                    {
                        if (val instanceof String)
                        {
                            Date date = DateUtils.parseDate(val);
                            val = StringUtils.isNull(date) ? null : DateUtils.toLocalDate(date);
                        }
                        else if (val instanceof Date)
                        {
                            val = DateUtils.toLocalDate((Date) val);
                        }
                        else if (val instanceof Double)
                        {
                            val = DateUtils.toLocalDate(DateUtil.getJavaDate((Double) val));
                        }
                    }
                    else if (Boolean.TYPE == fieldType || Boolean.class == fieldType)
                    {
                        val = Convert.toBool(val, false);
@@ -657,6 +673,15 @@
                        } else if (val instanceof Double) {
                            val = DateUtil.getJavaDate((Double) val);
                        }
                    } else if (LocalDate.class == fieldType) {
                        if (val instanceof String) {
                            Date date = DateUtils.parseDate(val);
                            val = StringUtils.isNull(date) ? null : DateUtils.toLocalDate(date);
                        } else if (val instanceof Date) {
                            val = DateUtils.toLocalDate((Date) val);
                        } else if (val instanceof Double) {
                            val = DateUtils.toLocalDate(DateUtil.getJavaDate((Double) val));
                        }
                    } else if (Boolean.TYPE == fieldType || Boolean.class == fieldType) {
                        val = Convert.toBool(val, false);
                    }
src/main/java/com/ruoyi/device/mapper/DeviceLedgerMapper.java
@@ -4,8 +4,8 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.dto.DeviceTypeDetail;
import com.ruoyi.account.dto.DeviceTypeDistributionVO;
import com.ruoyi.account.bean.dto.DeviceTypeDetail;
import com.ruoyi.account.bean.dto.DeviceTypeDistributionVO;
import com.ruoyi.device.dto.DeviceLedgerDto;
import com.ruoyi.device.execl.DeviceLedgerExeclDto;
import com.ruoyi.device.pojo.DeviceLedger;
src/main/java/com/ruoyi/device/service/impl/DeviceRepairServiceImpl.java
@@ -4,7 +4,6 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.xiaoymin.knife4j.core.util.CollectionUtils;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.utils.bean.BeanUtils;
@@ -123,7 +122,9 @@
                });
            }
            // å¤„理图片上传
            if (deviceRepairDto.getStorageBlobDTOs() != null) {
            fileUtil.saveStorageAttachmentByRecordTypeAndRecordId("file", RecordTypeEnum.DEVICE_REPAIR, id, deviceRepairDto.getStorageBlobDTOs());
            }
            return AjaxResult.success();
        }
        return AjaxResult.error();
src/main/java/com/ruoyi/procurementrecord/mapper/ReturnManagementMapper.java
@@ -3,6 +3,8 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesReturnDto;
import com.ruoyi.account.bean.vo.SalesReturnVo;
import com.ruoyi.procurementrecord.dto.ReturnManagementDto;
import com.ruoyi.procurementrecord.pojo.ReturnManagement;
import org.apache.ibatis.annotations.Param;
@@ -22,4 +24,6 @@
    IPage<ReturnManagementDto> listPage(Page page, @Param("req") ReturnManagementDto returnManagement);
    ReturnManagementDto getReturnManagementDtoById(Long id);
    IPage<SalesReturnVo> listPageBySalesReturn(Page page, @Param("req") SalesReturnDto salesReturnDto);
}
src/main/java/com/ruoyi/procurementrecord/service/impl/ReturnManagementServiceImpl.java
@@ -3,7 +3,7 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.bean.dto.SalesRefundAmountOrderDto;
import com.ruoyi.account.mapper.AccountExpenseMapper;
import com.ruoyi.account.pojo.AccountExpense;
import com.ruoyi.account.service.SalesRefundAmountOrderService;
@@ -30,6 +30,7 @@
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
@@ -61,8 +62,10 @@
    @Override
    public boolean addReturnManagementDto(ReturnManagementDto returnManagementDto) {
        if (ObjectUtils.isEmpty(returnManagementDto.getReturnNo())){
        String rt = OrderUtils.countTodayByCreateTime(returnManagementMapper, "RT","return_no");
        returnManagementDto.setReturnNo(rt);
        }
        save(returnManagementDto);
        for (ReturnSaleProduct returnSaleProduct : returnManagementDto.getReturnSaleProducts()) {
            returnSaleProduct.setReturnManagementId(returnManagementDto.getId());
src/main/java/com/ruoyi/procurementrecord/utils/StockUtils.java
@@ -81,6 +81,23 @@
    }
    /**
     * åˆæ ¼å…¥åº“带批次号
     * @param productModelId
     * @param quantity
     * @param recordType
     * @param recordId
     */
    public void addStockWithBatchNo(Long productModelId, BigDecimal quantity, String recordType, Long recordId, String batchNo) {
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setRecordId(recordId);
        stockInventoryDto.setRecordType(String.valueOf(recordType));
        stockInventoryDto.setQualitity(quantity);
        stockInventoryDto.setProductModelId(productModelId);
        stockInventoryDto.setBatchNo(batchNo);
        stockInventoryService.addStockInRecordOnly(stockInventoryDto);
    }
    /**
     * åˆæ ¼å‡ºåº“
     *
     * @param productModelId
src/main/java/com/ruoyi/production/bean/dto/ProductionAccountDto.java
@@ -9,54 +9,54 @@
import java.time.LocalDate;
@Data
@Schema(name = "ProductionAccountDto", description = "production account query dto")
@Schema(name = "ProductionAccountDto", description = "生产核算查询参数")
public class ProductionAccountDto extends ProductionAccount {
    @Schema(description = "sales contract no")
    @Schema(description = "销售合同号")
    private String salesContractNo;
    @Schema(description = "customer contract no")
    @Schema(description = "客户合同号")
    private String customerContractNo;
    @Schema(description = "project name")
    @Schema(description = "项目名称")
    private String projectName;
    @Schema(description = "customer name")
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "product category")
    @Schema(description = "产品类别")
    private String productCategory;
    @Schema(description = "specification model")
    @Schema(description = "规格型号")
    private String specificationModel;
    @Schema(description = "scheduling user id")
    @Schema(description = "排产人员ID")
    private Long schedulingUserId;
    @Schema(description = "scheduling user name")
    @Schema(description = "排产人员名称")
    private String schedulingUserName;
    @Schema(description = "process")
    @Schema(description = "工序")
    private String process;
    @Schema(description = "date type(day/month)")
    @Schema(description = "日期类型(按天/按月)")
    private String dateType;
    @Schema(description = "day query date")
    @Schema(description = "按天查询日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate entryDate;
    @Schema(description = "date range")
    @Schema(description = "日期范围")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate[] dateRange;
    @Schema(description = "start date")
    @Schema(description = "开始日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate entryDateStart;
    @Schema(description = "end date")
    @Schema(description = "结束日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate entryDateEnd;
src/main/java/com/ruoyi/production/bean/dto/ProductionPlanDto.java
@@ -52,4 +52,7 @@
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate requiredDateEnd;
    @Schema(description = "销售合同号")
    private String salesContractNo;
}
src/main/java/com/ruoyi/production/bean/dto/ProductionProductMainDto.java
@@ -12,63 +12,63 @@
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(name = "ProductionProductMainDto", description = "production report query dto")
@Schema(name = "ProductionProductMainDto", description = "生产报工查询参数")
public class ProductionProductMainDto extends ProductionProductMain {
    @Schema(description = "product process route item id")
    @Schema(description = "产品工艺路线工序ID")
    private Long productProcessRouteItemId;
    @Schema(description = "production report id")
    @Schema(description = "报工ID")
    private Long productMainId;
    @Schema(description = "tenant id")
    @Schema(description = "租户ID")
    private Long tenantId;
    @Schema(description = "work order no")
    @Schema(description = "工单编号")
    private String workOrderNo;
    @Schema(description = "work order status")
    @Schema(description = "工单状态")
    private String workOrderStatus;
    @Schema(description = "nick name")
    @Schema(description = "昵称")
    private String nickName;
    @Schema(description = "quantity")
    @Schema(description = "数量")
    private BigDecimal quantity;
    @Schema(description = "scrap quantity")
    @Schema(description = "报废数量")
    private BigDecimal scrapQty;
    @Schema(description = "product name")
    @Schema(description = "产品名称")
    private String productName;
    @Schema(description = "product model name")
    @Schema(description = "产品规格型号")
    private String productModelName;
    @Schema(description = "unit")
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "sales contract no")
    @Schema(description = "销售合同号")
    private String salesContractNo;
    @Schema(description = "scheduling date")
    @Schema(description = "排产日期")
    private LocalDate schedulingDate;
    @Schema(description = "scheduling user name")
    @Schema(description = "排产人员名称")
    private String schedulingUserName;
    @Schema(description = "customer name")
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "process")
    @Schema(description = "工序")
    private String process;
    @Schema(description = "salary quota")
    @Schema(description = "工资定额")
    private BigDecimal workHours;
    @Schema(description = "wages")
    @Schema(description = "工资")
    private BigDecimal wages;
    @Schema(description = "operation param list")
    @Schema(description = "工序参数列表")
    private List<ProductionOrderRoutingOperationParam> productionOperationParamList;
}
src/main/java/com/ruoyi/production/bean/vo/ProductionAccountVo.java
@@ -8,52 +8,55 @@
import java.time.LocalDate;
@Data
@Schema(name = "ProductionAccountVo", description = "production account page result")
@Schema(name = "ProductionAccountVo", description = "生产核算分页结果")
public class ProductionAccountVo {
    @Schema(description = "customer contract no")
    @Schema(description = "客户合同号")
    private String customerContractNo;
    @Schema(description = "project name")
    @Schema(description = "项目名称")
    private String projectName;
    @Schema(description = "customer name")
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "product category")
    @Schema(description = "产品类别")
    private String productCategory;
    @Schema(description = "specification model")
    @Schema(description = "规格型号")
    private String specificationModel;
    @Schema(description = "unit")
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "scheduling user id")
    @Schema(description = "排产人员ID")
    private Long schedulingUserId;
    @Schema(description = "scheduling user name")
    @Schema(description = "排产人员名称")
    private String schedulingUserName;
    @Schema(description = "wages")
    @Schema(description = "工资")
    private BigDecimal wages;
    @Schema(description = "finished quantity")
    @Schema(description = "完成数量")
    private BigDecimal finishedNum;
    @Schema(description = "salary quota")
    @Schema(description = "工资定额")
    private BigDecimal workHours;
    @Schema(description = "output rate")
    @Schema(description = "工时")
    private BigDecimal workHour;
    @Schema(description = "产出率")
    private String outputRate;
    @Schema(description = "process")
    @Schema(description = "工序")
    private String process;
    @Schema(description = "scheduling date")
    @Schema(description = "排产日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate schedulingDate;
    @Schema(description = "scheduling month(yyyy-MM)")
    @Schema(description = "排产月份(yyyy-MM)")
    private String schedulingMonth;
}
src/main/java/com/ruoyi/production/bean/vo/ProductionOperationTaskVo.java
@@ -46,4 +46,7 @@
    @Schema(description = "是否结束)")
    private Boolean endOrder;
    @Schema(description = "类型 åŒºåˆ†è®¡æ—¶å’Œè®¡ä»¶(0计时1计件)")
    private Integer type;
}
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderVo.java
@@ -1,6 +1,7 @@
package com.ruoyi.production.bean.vo;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import com.ruoyi.production.pojo.ProductionOrder;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -20,12 +21,15 @@
    private String customerName;
    @Schema(description = "产品名称")
    @Excel(name = "产品名称",sort = 2)
    private String productName;
    @Schema(description = "规格型号")
    @Excel(name = "规格",sort = 3)
    private String model;
    @Schema(description = "工艺路线编码")
    @Excel(name = "工艺路线编号",sort = 4)
    private String processRouteCode;
    @Schema(description = "产品图片")
@@ -35,6 +39,7 @@
    private String bomNo;
    @Schema(description = "完成进度")
    @Excel(name = "完成进度",sort = 7)
    private BigDecimal completionStatus;
    @Schema(description = "是否已退料")
src/main/java/com/ruoyi/production/bean/vo/ProductionOrderWorkOrderDetailVo.java
@@ -9,6 +9,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
@@ -42,6 +43,9 @@
        @Schema(description = "报工主信息")
        private ProductionProductMain reportMain;
        @Schema(description = "工时")
        private BigDecimal workHour;
        @Schema(description = "报工产出明细")
        private List<ProductionProductOutput> reportOutputList;
@@ -62,6 +66,9 @@
        @Schema(description = "报工主信息")
        private ProductionProductMain reportMain;
        @Schema(description = "工时")
        private BigDecimal workHour;
        @Schema(description = "质检主信息")
        private QualityInspect inspect;
src/main/java/com/ruoyi/production/controller/ProductionOrderController.java
@@ -1,7 +1,11 @@
package com.ruoyi.production.controller;
import cn.hutool.core.collection.CollUtil;
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.bean.dto.ProductionOrderDto;
import com.ruoyi.production.bean.vo.ProductionOrderPickVo;
@@ -10,13 +14,17 @@
import com.ruoyi.production.bean.vo.ProductionOrderWorkOrderDetailVo;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.service.ProductionOrderService;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.vo.SalesLedgerVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
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.ArrayList;
import java.util.List;
@RestController
@@ -95,4 +103,14 @@
    public R updateOrder(@RequestBody ProductionOrderDto productionOrderDto) {
        return R.ok(productionOrderService.updateOrder(productionOrderDto));
    }
    @Log(title = "生产订单导出", businessType = BusinessType.EXPORT)
    @PostMapping("/export")
    public void export(HttpServletResponse response, ProductionOrderDto dto) {
        IPage<ProductionOrderVo> productionOrderVoIPage = productionOrderService.pageProductionOrder(new Page<>(-1, -1), dto);
        List<ProductionOrderVo> records = productionOrderVoIPage.getRecords();
        ExcelUtil<ProductionOrderVo> util = new ExcelUtil<>(ProductionOrderVo.class);
        util.exportExcel(response, records, "生产订单数据");
    }
}
src/main/java/com/ruoyi/production/controller/ProductionPlanController.java
@@ -14,6 +14,7 @@
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -85,10 +86,10 @@
        excelUtil.importTemplateExcel(response, "主生产计划导入模板");
    }
    @PostMapping("/import")
    @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @Operation(summary = "主生产计划数据导入")
    @Log(title = "主生产计划数据导入", businessType = BusinessType.IMPORT)
    public R importProdData(@RequestParam("file") MultipartFile file) {
    public R importProdData(@RequestPart("file") MultipartFile file) {
        productionPlanService.importProdData(file);
        return R.ok("导入成功");
    }
@@ -96,8 +97,8 @@
    @PostMapping("/export")
    @Operation(summary = "主生产计划数据导出")
    @Log(title = "主生产计划数据导出", businessType = BusinessType.EXPORT)
    public void exportProdData(HttpServletResponse response, @RequestBody(required = false) List<Long> ids) {
        productionPlanService.exportProdData(response, ids);
    public void exportProdData(HttpServletResponse response, @RequestBody(required = false) ProductionPlanDto requestDto) {
        productionPlanService.exportProdData(response, requestDto);
    }
}
src/main/java/com/ruoyi/production/pojo/ProductionOrder.java
@@ -2,6 +2,7 @@
import com.baomidou.mybatisplus.annotation.*;
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;
@@ -36,6 +37,7 @@
    private Long productModelId;
    @Schema(description = "生产订单号")
    @Excel(name = "生产订单",sort = 0)
    private String npsNo;
    @Schema(description = "录入时间")
@@ -50,15 +52,19 @@
    private Long technologyRoutingId;
    @Schema(description = "需求数量。手动新增时必填且必须大于 0;如果传了 productionPlanIds,则可由系统自动带出。")
    @Excel(name = "需求数量",sort = 5)
    private BigDecimal quantity;
    @Schema(description = "完成数量")
    @Excel(name = "完成数量",sort = 6)
    private BigDecimal completeQuantity;
    @Schema(description = "开始日期")
    @Excel(name = "开始日期",sort = 8,dateFormat = "yyyy-MM-dd")
    private LocalDateTime startTime;
    @Schema(description = "结束日期")
    @Excel(name = "结束日期",sort = 9,dateFormat = "yyyy-MM-dd")
    private LocalDateTime endTime;
    @Schema(description = "创建人ID")
@@ -72,9 +78,11 @@
    @Schema(description = "计划完成时间")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "计划完成时间",sort = 10,dateFormat = "yyyy-MM-dd")
    private LocalDate planCompleteTime;
    @Schema(description = "状态(1.待开始 2.进行中 3.已完成 4.已取消 5.已结束)")
    @Excel(name = "状态",sort = 1,readConverterExp = "1=待开始,2=进行中,3=已完成,4=已取消,5=已结束")
    private Integer status;
    @Schema(description = "是否结束)")
src/main/java/com/ruoyi/production/pojo/ProductionOrderRoutingOperation.java
@@ -67,4 +67,7 @@
    @Schema(description = "工序表id")
    private Long technologyOperationId;
    @Schema(description = "类型 åŒºåˆ†è®¡æ—¶å’Œè®¡ä»¶ï¼Œ0计时,1计件")
    private Integer type;
}
src/main/java/com/ruoyi/production/pojo/ProductionProductMain.java
@@ -7,6 +7,7 @@
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@@ -56,4 +57,7 @@
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    @Schema(description = "工时")
    private BigDecimal workHour;
}
src/main/java/com/ruoyi/production/service/ProductionPlanService.java
@@ -51,6 +51,6 @@
    /**
     * å¯¼å‡ºæ•°æ®
     */
    void exportProdData(HttpServletResponse response, List<Long> ids);
    void exportProdData(HttpServletResponse response, ProductionPlanDto requestDto);
}
src/main/java/com/ruoyi/production/service/impl/ProductionAccountServiceImpl.java
@@ -24,16 +24,19 @@
    @Override
    public IPage<ProductionAccountVo> listPage(Page<ProductionAccountDto> page, ProductionAccountDto dto) {
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§æ ¸ç®—数据
        ProductionAccountDto queryDto = normalizeDateQuery(dto);
        return baseMapper.listPage(page, queryDto);
    }
    @Override
    public IPage<ProductionProductMainDto> listProductionDetails(ProductionAccountDto dto, Page page) {
        // æŸ¥è¯¢ç”Ÿäº§æ ¸ç®—明细
        return productionProductMainMapper.listProductionDetails(normalizeDateQuery(dto), page);
    }
    private ProductionAccountDto normalizeDateQuery(ProductionAccountDto dto) {
        // è§„范日期查询范围,补齐缺失的开始或结束时间
        if (dto == null) {
            return new ProductionAccountDto();
        }
src/main/java/com/ruoyi/production/service/impl/ProductionBomStructureServiceImpl.java
@@ -33,6 +33,7 @@
     */
    @Override
    public List<ProductionBomStructureVo> listByBomId(Long bomId) {
        // æŒ‰BOMID查询生产结构数据
        List<ProductionBomStructureVo> list = productionBomStructureMapper.listByBomId(bomId);
        Map<Long, ProductionBomStructureVo> map = new HashMap<>();
        for (ProductionBomStructureVo node : list) {
@@ -58,13 +59,17 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean addProductionBomStructure(ProductionBomStructureDto dto) {
        // æ–°å¢žç”Ÿäº§BOM结构
        // è¯»å–当前订单BOM主键,并把前端树结构拍平成列表
        Long orderBomId = dto.getProductionOrderBomId();
        List<ProductionBomStructureDto> flatDtoList = new ArrayList<>();
        flattenTree(dto.getChildren(), flatDtoList);
        // æŸ¥è¯¢æ•°æ®åº“已有结构,用于后续做增删改对比
        List<ProductionBomStructure> dbList = this.list(new LambdaQueryWrapper<ProductionBomStructure>()
                .eq(ProductionBomStructure::getProductionOrderBomId, orderBomId));
        // æ”¶é›†å‰ç«¯ä»ç„¶å­˜åœ¨çš„节点ID
        Set<Long> frontendIds = new HashSet<>();
        for (ProductionBomStructureDto item : flatDtoList) {
            if (item.getId() != null) {
@@ -72,16 +77,19 @@
            }
        }
        // è®¡ç®—需要删除的节点(数据库有、前端已删除)
        Set<Long> deleteIds = new HashSet<>();
        for (ProductionBomStructure dbItem : dbList) {
            if (!frontendIds.contains(dbItem.getId())) {
                deleteIds.add(dbItem.getId());
            }
        }
        // å…ˆåˆ æŽ‰å‰ç«¯å·²ç»ç§»é™¤çš„节点
        if (!deleteIds.isEmpty()) {
            this.removeByIds(deleteIds);
        }
        // æŒ‰æ˜¯å¦æœ‰ID拆分为新增和更新,同时缓存新增节点的临时ID映射
        List<ProductionBomStructure> insertList = new ArrayList<>();
        List<ProductionBomStructure> updateList = new ArrayList<>();
        Map<String, ProductionBomStructure> tempEntityMap = new HashMap<>();
@@ -99,10 +107,12 @@
            }
        }
        // æ‰¹é‡æ–°å¢žï¼Œæ‹¿åˆ°æ•°æ®åº“生成的真实ID
        if (!insertList.isEmpty()) {
            this.saveBatch(insertList);
        }
        // æ–°å¢žèŠ‚ç‚¹äºŒæ¬¡å›žå†™çˆ¶ID(前端传的是临时父ID)
        List<ProductionBomStructure> parentFixList = new ArrayList<>();
        for (ProductionBomStructureDto item : flatDtoList) {
            if (item.getId() == null && item.getParentTempId() != null) {
@@ -111,15 +121,18 @@
                    continue;
                }
                ProductionBomStructure parent = tempEntityMap.get(item.getParentTempId());
                // çˆ¶èŠ‚ç‚¹æ˜¯æœ¬æ¬¡æ–°å¢žæ—¶ï¼Œç›´æŽ¥ç”¨æ–°å¢žåŽçš„çœŸå®žID;否则回退为前端传入父ID
                Long realParentId = parent != null ? parent.getId() : Long.valueOf(item.getParentTempId());
                child.setParentId(realParentId);
                parentFixList.add(child);
            }
        }
        // å›žå†™æ–°å¢žèŠ‚ç‚¹çš„çˆ¶å­å…³ç³»
        if (!parentFixList.isEmpty()) {
            this.updateBatchById(parentFixList);
        }
        // æ‰¹é‡æ›´æ–°å·²æœ‰èŠ‚ç‚¹
        if (!updateList.isEmpty()) {
            this.updateBatchById(updateList);
        }
@@ -130,6 +143,7 @@
     * å°†æ ‘形结构拍平成列表,便于统一保存。
     */
    private void flattenTree(List<ProductionBomStructureDto> source, List<ProductionBomStructureDto> result) {
        // æ‰å¹³åŒ–处理树
        if (source == null) {
            return;
        }
src/main/java/com/ruoyi/production/service/impl/ProductionOperationTaskServiceImpl.java
@@ -58,6 +58,7 @@
    @Override
    public IPage<ProductionOperationTaskVo> pageProductionOperationTask(Page<ProductionOperationTaskDto> page, ProductionOperationTaskDto dto) {
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§å·¥åºä»»åŠ¡
        Page<ProductionOperationTaskVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
        IPage<ProductionOperationTaskVo> result = baseMapper.pageProductionOperationTask(voPage, dto);
        fillUserNames(result.getRecords());
@@ -66,6 +67,7 @@
    @Override
    public List<ProductionOperationTaskVo> listProductionOperationTask(ProductionOperationTaskDto dto) {
        // æŸ¥è¯¢å·¥åºä»»åŠ¡åˆ—è¡¨
        List<ProductionOperationTaskVo> result = BeanUtil.copyToList(this.list(buildQueryWrapper(dto)), ProductionOperationTaskVo.class);
        fillUserNames(result);
        return result;
@@ -73,6 +75,7 @@
    @Override
    public ProductionOperationTaskVo getProductionOperationTaskInfo(Long id) {
        // èŽ·å–ç”Ÿäº§å·¥åºä»»åŠ¡è¯¦æƒ…
        ProductionOperationTask item = this.getById(id);
        if (item == null) {
            return null;
@@ -90,21 +93,25 @@
    @Override
    public boolean saveProductionOperationTask(ProductionOperationTask productionOperationTask) {
        // ä¿å­˜ç”Ÿäº§å·¥åºä»»åŠ¡
        return this.saveOrUpdate(productionOperationTask);
    }
    @Override
    public boolean removeProductionOperationTask(List<Long> ids) {
        // åˆ é™¤ç”Ÿäº§å·¥åºä»»åŠ¡
        return ids != null && !ids.isEmpty() && this.removeByIds(ids);
    }
    @Override
    public int updateProductWorkOrder(ProductionOperationTaskDto dto) {
        // æ›´æ–°å·¥åºä»»åŠ¡å¯¹åº”çš„å·¥å•ä¿¡æ¯
        return baseMapper.updateById(dto);
    }
    @Override
    public boolean assign(ProductionOperationTaskDto dto) {
        // åˆ†é…å·¥åºä»»åŠ¡æ‰§è¡Œäºº
        if (dto == null || dto.getId() == null) {
            throw new ServiceException("工单ID不能为空");
        }
@@ -120,6 +127,7 @@
    }
    private LambdaQueryWrapper<ProductionOperationTask> buildQueryWrapper(ProductionOperationTaskDto dto) {
        // æŒ‰æ¡ä»¶åŠ¨æ€æž„å»ºæ•°æ®åº“æŸ¥è¯¢æ¡ä»¶
        ProductionOperationTask query = dto == null ? new ProductionOperationTask() : dto;
        return Wrappers.<ProductionOperationTask>lambdaQuery()
                .eq(query.getId() != null, ProductionOperationTask::getId, query.getId())
@@ -133,10 +141,12 @@
    }
    private void fillUserNames(List<ProductionOperationTaskVo> voList) {
        // å¡«å……用户名称
        if (voList == null || voList.isEmpty()) {
            return;
        }
        Set<Long> userIdSet = new LinkedHashSet<>();
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        for (ProductionOperationTaskVo vo : voList) {
            if (vo == null) {
                continue;
@@ -172,6 +182,7 @@
    }
    private List<Long> parseUserIdList(String userIds, boolean strict) {
        // è§£æžå¹¶æ ¡éªŒç”¨æˆ·ID数组字符串
        if (StringUtils.isBlank(userIds)) {
            if (strict) {
                throw new ServiceException("userIds格式不正确,必须为JSON数字数组");
@@ -199,6 +210,7 @@
    @Override
    public void down(HttpServletResponse response, ProductionOperationTaskDto dto) {
        // å¯¼å‡ºå·¥åºä»»åŠ¡æ•°æ®
        if (dto == null || dto.getId() == null) {
            throw new ServiceException("工单ID不能为空");
        }
@@ -250,15 +262,18 @@
    }
    private List<Map<String, Object>> buildTaskAttachmentImages(Long taskId) {
        // ç»„装任务附件图片数据用于导出
        List<Map<String, Object>> images = new ArrayList<>();
        StorageAttachmentDTO storageAttachmentDTO = new StorageAttachmentDTO();
        storageAttachmentDTO.setRecordType(RecordTypeEnum.PRODUCTION_OPERATION_TASK.getType());
        storageAttachmentDTO.setRecordId(taskId);
        List<StorageBlobVO> taskWorkOrderFiles =
                fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(storageAttachmentDTO);
        // å‚数与前置条件校验
        if (CollectionUtils.isEmpty(taskWorkOrderFiles)) {
            return images;
        }
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        for (StorageBlobVO blobVO : taskWorkOrderFiles) {
            if (blobVO == null) {
                continue;
@@ -286,6 +301,7 @@
    }
    private File resolveImageFile(StorageBlobVO blobVO) {
        // å°†é™„件信息解析为本地图片文件对象
        if (blobVO == null || StringUtils.isBlank(blobVO.getUidFilename())) {
            return null;
        }
@@ -296,6 +312,7 @@
    }
    private PictureType resolvePictureType(StorageBlobVO blobVO) {
        // æŒ‰æ–‡ä»¶åæˆ–内容类型识别图片格式
        if (blobVO == null) {
            return null;
        }
@@ -311,6 +328,7 @@
    }
    private PictureType parsePictureTypeByFileName(String fileName) {
        // æ ¹æ®æ–‡ä»¶åŽç¼€è§£æžå›¾ç‰‡æ ¼å¼
        if (StringUtils.isBlank(fileName) || !fileName.contains(".")) {
            return null;
        }
@@ -322,6 +340,7 @@
    }
    private PictureType parsePictureTypeByContentType(String contentType) {
        // æ ¹æ®Content-Type解析图片格式
        if (StringUtils.isBlank(contentType)) {
            return null;
        }
@@ -350,6 +369,7 @@
    @Override
    public List<ProductionOperationTaskVo> getOperation(ProductionOperationTaskDto dto) {
        // æŸ¥è¯¢å·¥åºä»»åŠ¡åˆ—è¡¨
        return baseMapper.getOperation(dto);
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickRecordServiceImpl.java
@@ -24,6 +24,7 @@
    @Override
    public List<ProductionOrderPickRecordVo> listFeedingRecord(ProductionOrderPickRecordDto dto) {
        // æŸ¥è¯¢æŠ•料记录
        if (dto == null || dto.getProductionOrderId() == null || dto.getPickId() == null) {
            return Collections.emptyList();
        }
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java
@@ -3,7 +3,9 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOrderPickDto;
@@ -18,8 +20,12 @@
import com.ruoyi.production.service.ProductionOrderPickService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInRecord;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.pojo.StockOutRecord;
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockInRecordService;
import com.ruoyi.stock.service.StockOutRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,12 +36,8 @@
import java.util.stream.Collectors;
/**
 * <p>
 * ç’ãˆ å´Ÿæ£°å—˜æž¡ç»¾èƒ¯ç«Ÿæµ ?鏈嶅姟瀹炵幇绫?
 * </p>
 *
 * @author é‘ºîˆšî‡±æžîˆ™æ¬¢é”›å Ÿç™é‘»å¿¥ç´šéˆå¤æªºéî„€å¾ƒ
 * @since 2026-04-21 03:55:52
 * ç”Ÿäº§è®¢å•领料服务实现。
 * è´Ÿè´£é¢†æ–™æ–°å¢žã€æ›´æ–°ã€è¡¥æ–™ã€é€€æ–™åŠåº“存联动。
 */
@Service
@RequiredArgsConstructor
@@ -43,27 +45,42 @@
    private static final byte PICK_TYPE_NORMAL = 1;
    private static final byte PICK_TYPE_FEEDING = 2;
    private static final String PICK_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.PICK_STOCK_OUT.getCode();
    private static final String FEED_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.FEED_STOCK_OUT.getCode();
    private static final String PICK_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode();
    private static final String FEED_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.FEED_RETURN_IN.getCode();
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    private final StockInRecordService stockInRecordService;
    private final StockOutRecordService stockOutRecordService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean savePick(ProductionOrderPickDto dto) {
        // é¢†æ–™æ–°å¢žæ€»æµç¨‹ï¼š
        // 1) è§£æžå‰ç«¯è¡Œæ•°æ®å¹¶é€è¡Œåˆå¹¶å‚æ•°ï¼›
        // 2) æ ¡éªŒå‚数与批次;
        // 3) å…ˆä¿å­˜é¢†æ–™ä¸»è®°å½•ï¼›
        // 4) å†èµ°â€œå‡ºåº“申请 + å®¡æ‰¹é€šè¿‡â€å®Œæˆåº“存扣减;
        // 5) å†™å…¥é¢†æ–™æµæ°´ï¼Œè®°å½•数量变化轨迹。
        List<ProductionOrderPickDto> pickItems = resolvePickItems(dto);
        // é€è¡Œå¤„理领料数据,行号用于拼装精确的报错信息。
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
            // æ¯è¡Œéƒ½åšå®Œæ•´æ ¡éªŒï¼Œå¼‚常信息带行号。
            validatePickParam(resolvedDto, rowNo);
            // ç»Ÿä¸€å¤„理批次(支持单批次/多批次)。
            List<String> batchNoList = resolveBatchNoList(resolvedDto);
            String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
            String storedBatchNo = formatBatchNoStorage(batchNoList);
            subtractInventory(resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo);
            // ä¿å­˜é¢†æ–™ä¸»è®°å½•快照。
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(resolvedDto.getProductionOrderId());
            orderPick.setProductModelId(resolvedDto.getProductModelId());
@@ -75,8 +92,13 @@
            orderPick.setDemandedQuantity(resolvedDto.getDemandedQuantity());
            orderPick.setBom(resolvedDto.getBom());
            orderPick.setReturned(false);
            // æ–°å¢žä¸»è®°å½•。
            baseMapper.insert(orderPick);
            // å…ˆæ–°å¢žå‡ºåº“申请,再审批通过,完成库存扣减。
            subtractInventory(orderPick.getId(), resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
            // è®°å½•本次领料流水(before=0,after=本次领料量)。
            insertPickRecord(orderPick.getId(),
                    resolvedDto.getProductionOrderId(),
                    resolvedDto.getProductionOperationTaskId(),
@@ -95,8 +117,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean updatePick(ProductionOrderPickDto dto) {
        // é¢†æ–™æ›´æ–°å…¥å£ï¼ˆåŒæŽ¥å£å…¼å®¹ä¸‰ç±»ä¸šåŠ¡ï¼‰ï¼š
        // 1) æ™®é€šé¢†æ–™æ”¹é‡/增删;
        // 2) è¡¥æ–™ï¼ˆpickType=2);
        // 3) é€€æ–™ï¼ˆreturned=true)。
        if (dto == null) {
            throw new ServiceException("变更参数不能为空");
            throw new ServiceException("参数不能为空");
        }
        Long productionOrderId = resolveProductionOrderId(dto);
        if (productionOrderId == null) {
@@ -107,26 +133,32 @@
            throw new ServiceException("生产订单不存在");
        }
        // æŸ¥è¯¢è®¢å•下现有领料记录并构建ID索引。
        List<ProductionOrderPick> existingPickList = baseMapper.selectList(
                Wrappers.<ProductionOrderPick>lambdaQuery()
                        .eq(ProductionOrderPick::getProductionOrderId, productionOrderId));
        // è½¬æˆMap便于后续按ID快速校验与更新。
        Map<Long, ProductionOrderPick> existingPickMap = existingPickList.stream()
                .filter(item -> item.getId() != null)
                .collect(Collectors.toMap(ProductionOrderPick::getId, Function.identity(), (a, b) -> a));
        // è¡¥æ–™è¯·æ±‚单独走补料分支。
        if (isFeedingRequest(dto)) {
            processFeedingPickItems(dto, existingPickMap, productionOrderId);
            return true;
        }
        // é€€æ–™è¯·æ±‚单独走退料分支。
        if (isReturnRequest(dto)) {
            processReturnPickItems(dto, existingPickMap, productionOrderId);
            return true;
        }
        // æ™®é€šæ›´æ–°åœºæ™¯å…ˆå¤„理显式删除。
        processDeletePickIds(dto, existingPickMap, productionOrderId);
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(dto);
        Set<Long> keepPickIdSet = new HashSet<>();
        // keepPickIdSet ç”¨äºŽæ ‡è®°æœ¬æ¬¡å‰ç«¯ä»ç„¶ä¿ç•™çš„æ—§è®°å½•,后续用于识别“未回传即删除”的行。
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
@@ -145,12 +177,14 @@
            keepPickIdSet.add(resolvedDto.getId());
            updateExistingPick(resolvedDto, rowNo, existingPickMap);
        }
        // æ¸…理前端未回传旧行并回补库存。
        processMissingPickItems(dto, existingPickMap, productionOrderId, keepPickIdSet);
        return true;
    }
    @Override
    public List<ProductionOrderPickVo> listPickedDetail(Long productionOrderId) {
        // æŸ¥è¯¢è®¢å•领料明细,并补齐批次展示字段。
        if (productionOrderId == null) {
            return Collections.emptyList();
        }
@@ -163,6 +197,11 @@
    private void processDeletePickIds(ProductionOrderPickDto rootDto,
                                      Map<Long, ProductionOrderPick> existingPickMap,
                                      Long productionOrderId) {
        // å¤„理前端显式删除ID:
        // 1) æ ¡éªŒåˆ é™¤ç›®æ ‡æ˜¯å¦å±žäºŽå½“前订单;
        // 2) å›žè¡¥åº“存;
        // 3) åˆ é™¤ä¸»è®°å½•ï¼›
        // 4) è®°å½•删除流水。
        if (rootDto.getDeletePickIds() == null || rootDto.getDeletePickIds().isEmpty()) {
            return;
        }
@@ -173,14 +212,19 @@
            }
            ProductionOrderPick existingPick = existingPickMap.get(deleteId);
            if (existingPick == null || !Objects.equals(existingPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("要删除的领料记录不存在或不属于当前订单,ID=" + deleteId);
                throw new ServiceException("删除失败:领料记录不存在或不属于当前订单,ID=" + deleteId);
            }
            String oldBatchNo = resolveInventoryBatchNoFromStored(existingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(existingPick.getQuantity());
            addInventory(existingPick.getProductModelId(), oldBatchNo, oldQuantity);
            addInventory(existingPick.getId(), existingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // åˆ é™¤å…³è”领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, existingPick.getId())
            );
            int affected = baseMapper.deleteById(deleteId);
            if (affected <= 0) {
                throw new ServiceException("删除领料失败,ID=" + deleteId);
                throw new ServiceException("删除领料记录失败,ID=" + deleteId);
            }
            insertPickRecord(existingPick.getId(),
                    existingPick.getProductionOrderId(),
@@ -201,6 +245,9 @@
                                         Map<Long, ProductionOrderPick> existingPickMap,
                                         Long productionOrderId,
                                         Set<Long> keepPickIdSet) {
        // å¤„理“前端未回传”的旧行:
        // å¯¹åº”场景是用户在前端删除行但未放入 deletePickIds。
        // è¿™é‡Œå…œåº•识别并执行回补库存 + åˆ é™¤ä¸»è®°å½• + å†™æµæ°´ã€‚
        if (rootDto.getPickList() == null) {
            return;
        }
@@ -213,10 +260,15 @@
        for (ProductionOrderPick missingPick : missingPickList) {
            String oldBatchNo = resolveInventoryBatchNoFromStored(missingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(missingPick.getQuantity());
            addInventory(missingPick.getProductModelId(), oldBatchNo, oldQuantity);
            addInventory(missingPick.getId(), missingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // åˆ é™¤å…³è”领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, missingPick.getId())
            );
            int affected = baseMapper.deleteById(missingPick.getId());
            if (affected <= 0) {
                throw new ServiceException("删除领料失败,ID=" + missingPick.getId());
                throw new ServiceException("删除未回传领料记录失败,ID=" + missingPick.getId());
            }
            insertPickRecord(missingPick.getId(),
                    missingPick.getProductionOrderId(),
@@ -234,10 +286,11 @@
    }
    private void addNewPickInUpdate(ProductionOrderPickDto dto, int rowNo) {
        // æ›´æ–°åœºæ™¯ä¸‹æ–°å¢žä¸€æ¡é¢†æ–™ï¼š
        // æ–°å¢žä¸»è®°å½• -> å‡ºåº“申请并审批 -> å†™æµæ°´ã€‚
        List<String> batchNoList = resolveBatchNoList(dto);
        String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
        String storedBatchNo = formatBatchNoStorage(batchNoList);
        subtractInventory(dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo);
        ProductionOrderPick orderPick = new ProductionOrderPick();
        orderPick.setProductionOrderId(dto.getProductionOrderId());
@@ -251,6 +304,9 @@
        orderPick.setBom(dto.getBom());
        orderPick.setReturned(false);
        baseMapper.insert(orderPick);
        // å…ˆæ–°å¢žå‡ºåº“申请,再审批通过,完成库存扣减。
        subtractInventory(orderPick.getId(), dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
        insertPickRecord(orderPick.getId(),
                dto.getProductionOrderId(),
@@ -268,6 +324,8 @@
    private void processFeedingPickItems(ProductionOrderPickDto rootDto,
                                         Map<Long, ProductionOrderPick> existingPickMap,
                                         Long productionOrderId) {
        // è¡¥æ–™æµç¨‹å…¥å£ï¼š
        // é€è¡Œæ ¡éªŒè¡¥æ–™å‚数,校验原领料归属,再执行补料库存扣减和主记录回写。
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(rootDto);
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
@@ -276,7 +334,7 @@
                continue;
            }
            if (!isFeedingPick(resolvedDto)) {
                throw new ServiceException("补料请求中的领料类型必须全部为2");
                throw new ServiceException("补料请求中存在非补料类型数据");
            }
            if (resolvedDto.getProductionOrderId() == null) {
                resolvedDto.setProductionOrderId(productionOrderId);
@@ -285,15 +343,20 @@
            ProductionOrderPick oldPick = existingPickMap.get(resolvedDto.getId());
            if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("第" + rowNo + "条领料记录不存在或不属于当前订单");
                throw new ServiceException("第" + rowNo + "行补料失败:未找到对应的领料记录");
            }
            addFeedingPick(resolvedDto, oldPick, rowNo);
        }
    }
    private void addFeedingPick(ProductionOrderPickDto dto, ProductionOrderPick oldPick, int rowNo) {
        // è¡¥æ–™æ ¸å¿ƒï¼š
        // 1) æ ¡éªŒè§„格一致;
        // 2) æ‰£å‡è¡¥æ–™åº“存;
        // 3) å†™è¡¥æ–™æµæ°´ï¼›
        // 4) å›žå†™ä¸»å•累计补料量和实际量。
        if (dto.getProductModelId() != null && !Objects.equals(dto.getProductModelId(), oldPick.getProductModelId())) {
            throw new ServiceException("第" + rowNo + "条补料产品规格与领料记录不一致");
            throw new ServiceException("第" + rowNo + "行补料失败:产品规格与原领料记录不一致");
        }
        Long productModelId = oldPick.getProductModelId();
        List<String> batchNoList = resolveBatchNoList(dto);
@@ -302,8 +365,9 @@
                : formatBatchNoStorage(batchNoList);
        BigDecimal feedingQuantity = dto.getFeedingQuantity();
        subtractInventory(productModelId, inventoryBatchNo, feedingQuantity, rowNo);
        subtractInventory(oldPick.getId(), productModelId, inventoryBatchNo, feedingQuantity, rowNo, FEED_STOCK_OUT_RECORD_TYPE);
        // è®¡ç®—补料前后数量并写补料流水。
        BigDecimal beforeFeedingQty = sumFeedingQuantity(dto.getProductionOrderId(), oldPick.getId());
        BigDecimal afterFeedingQty = beforeFeedingQty.add(feedingQuantity);
        insertPickRecord(oldPick.getId(),
@@ -322,9 +386,10 @@
        updatePick.setId(oldPick.getId());
        updatePick.setFeedingQty(afterFeedingQty);
        updatePick.setActualQty(calculateActualQty(oldPick, afterFeedingQty));
        // å›žå†™ä¸»è®°å½•的补料累计值与实际用量。
        int affected = baseMapper.updateById(updatePick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "条补料总量更新失败");
            throw new ServiceException("第" + rowNo + "行补料失败:更新领料主记录失败");
        }
        oldPick.setFeedingQty(afterFeedingQty);
        oldPick.setActualQty(updatePick.getActualQty());
@@ -333,6 +398,8 @@
    private void processReturnPickItems(ProductionOrderPickDto rootDto,
                                        Map<Long, ProductionOrderPick> existingPickMap,
                                        Long productionOrderId) {
        // é€€æ–™æµç¨‹å…¥å£ï¼š
        // é€è¡Œæ ¡éªŒé€€æ–™å‚数与领料归属,再更新退料量与实际量字段。
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(rootDto);
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
@@ -347,33 +414,56 @@
            ProductionOrderPick oldPick = existingPickMap.get(resolvedDto.getId());
            if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("第" + rowNo + "条领料记录不存在或不属于当前订单");
                throw new ServiceException("第" + rowNo + "行退料失败:未找到对应的领料记录");
            }
            updateReturnPick(resolvedDto, oldPick, rowNo);
        }
    }
    private void updateReturnPick(ProductionOrderPickDto dto, ProductionOrderPick oldPick, int rowNo) {
        // é€€æ–™æ›´æ–°ï¼š
        // 1) returnQty æŒ‰â€œæœ¬æ¬¡é€€æ–™é‡â€å¤„理;
        // 2) æœ¬æ¬¡é€€æ–™é‡å›žè¡¥åˆ°â€œç”Ÿäº§é€€æ–™å…¥åº“”;
        // 3) ç´¯åŠ ä¸»è®°å½•é€€æ–™æ€»é‡å¹¶é‡ç®—å®žé™…é‡ã€‚
        BigDecimal oldReturnQty = defaultDecimal(oldPick.getReturnQty());
        BigDecimal currentReturnQty = defaultDecimal(dto.getReturnQty());
        BigDecimal totalReturnQty = oldReturnQty.add(currentReturnQty);
        if (currentReturnQty.compareTo(BigDecimal.ZERO) > 0) {
            String returnBatchNo = resolveInventoryBatchNoFromStored(oldPick.getBatchNo());
            addInventoryRecordOnly(oldPick.getId(), oldPick.getProductModelId(), returnBatchNo, currentReturnQty, FEED_RETURN_IN_RECORD_TYPE);
        }
        BigDecimal actualQty = defaultDecimal(oldPick.getQuantity())
                .add(defaultDecimal(oldPick.getFeedingQty()))
                .subtract(totalReturnQty);
        if (actualQty.compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行退料失败:累计退料数量不能大于可用数量");
        }
        ProductionOrderPick updatePick = new ProductionOrderPick();
        updatePick.setId(oldPick.getId());
        updatePick.setReturnQty(dto.getReturnQty());
        updatePick.setActualQty(dto.getActualQty());
        updatePick.setReturned(true);
        updatePick.setReturnQty(totalReturnQty);
        updatePick.setActualQty(actualQty);
        updatePick.setReturned(totalReturnQty.compareTo(BigDecimal.ZERO) > 0);
        int affected = baseMapper.updateById(updatePick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "条退料信息更新失败");
            throw new ServiceException("第" + rowNo + "行退料失败:更新领料主记录失败");
        }
        oldPick.setReturnQty(updatePick.getReturnQty());
        oldPick.setActualQty(updatePick.getActualQty());
        oldPick.setReturned(true);
        oldPick.setReturned(updatePick.getReturned());
    }
    private void updateExistingPick(ProductionOrderPickDto dto,
                                    int rowNo,
                                    Map<Long, ProductionOrderPick> existingPickMap) {
        // æ™®é€šæ›´æ–°å•行核心流程:
        // 1) æ ¡éªŒæ—§è®°å½•存在且属于当前订单;
        // 2) æ¯”较新旧“规格+批次”,决定库存处理策略;
        // 3) æ›´æ–°ä¸»è®°å½•ï¼›
        // 4) å†™å˜æ›´æµæ°´ï¼ˆè®°å½•前后数量变化)。
        ProductionOrderPick oldPick = existingPickMap.get(dto.getId());
        if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), dto.getProductionOrderId())) {
            throw new ServiceException("第" + rowNo + "条领料记录不存在或不属于当前订单");
            throw new ServiceException("第" + rowNo + "行更新失败:未找到对应的领料记录");
        }
        Long oldProductModelId = oldPick.getProductModelId();
@@ -386,18 +476,30 @@
        String newStoredBatchNo = formatBatchNoStorage(newBatchNoList);
        BigDecimal newQuantity = dto.getPickQuantity();
        // åˆ¤æ–­è§„æ ¼+批次或数量是否变化,并按场景处理库存:
        // 1) åŒè§„格同批次:按差值处理(增量扣减 / å‡é‡å›žé€€ï¼‰ï¼›
        // 2) è§„格或批次变化:回退旧领料后再重提新领料。
        boolean sameStockKey = Objects.equals(oldProductModelId, newProductModelId)
                && Objects.equals(oldBatchNo, newBatchNo);
        boolean quantityChanged = oldQuantity.compareTo(newQuantity) != 0;
        boolean needReissuePickRecord = !sameStockKey || quantityChanged;
        if (sameStockKey) {
            BigDecimal delta = newQuantity.subtract(oldQuantity);
            if (delta.compareTo(BigDecimal.ZERO) > 0) {
                subtractInventory(newProductModelId, newStoredBatchNo, delta, rowNo);
            } else if (delta.compareTo(BigDecimal.ZERO) < 0) {
                addInventory(oldProductModelId, oldBatchNo, delta.abs());
            BigDecimal deltaQuantity = newQuantity.subtract(oldQuantity);
            if (deltaQuantity.compareTo(BigDecimal.ZERO) > 0) {
                // æ•°é‡å¢žåŠ ï¼Œåªæ‰£å‡æ–°å¢žéƒ¨åˆ†ã€‚
                subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, deltaQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
            } else if (deltaQuantity.compareTo(BigDecimal.ZERO) < 0) {
                // æ•°é‡å‡å°‘,只回退差值部分。
                addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, deltaQuantity.abs(), PICK_RETURN_IN_RECORD_TYPE);
            }
        } else {
            addInventory(oldProductModelId, oldBatchNo, oldQuantity);
            subtractInventory(newProductModelId, newStoredBatchNo, newQuantity, rowNo);
            // è§„格或批次变化:先全量回退旧领料,再全量扣减新领料。
            addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, newQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
        }
        if (needReissuePickRecord) {
            // æ­£å¸¸é¢†æ–™æµæ°´æŒ‰â€œæœ€æ–°é¢†æ–™é‡â€é‡å»ºï¼Œé¿å…ä¿ç•™åŽ†å²æ—§å€¼ã€‚
            deleteNormalPickRecord(oldPick.getId());
        }
        oldPick.setProductModelId(newProductModelId);
@@ -406,6 +508,9 @@
        oldPick.setRemark(dto.getRemark());
        oldPick.setOperationName(dto.getOperationName());
        oldPick.setTechnologyOperationId(dto.getTechnologyOperationId());
        // æ™®é€šæ›´æ–°ä¹Ÿè¦åŒæ­¥é‡ç®—实际用量,避免沿用旧值。
        // è§„则:实际用量 = é¢†æ–™æ•°é‡ + è¡¥æ–™æ•°é‡ - é€€æ–™æ•°é‡ã€‚
        oldPick.setActualQty(calculateActualQty(oldPick, oldPick.getFeedingQty()));
        if (dto.getDemandedQuantity() != null) {
            oldPick.setDemandedQuantity(dto.getDemandedQuantity());
        }
@@ -414,18 +519,18 @@
        }
        int affected = baseMapper.updateById(oldPick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "条领料更新失败");
            throw new ServiceException("第" + rowNo + "行更新失败:更新领料记录失败");
        }
        BigDecimal recordQuantity = sameStockKey ? oldQuantity.subtract(newQuantity).abs() : newQuantity;
        if (recordQuantity.compareTo(BigDecimal.ZERO) > 0 || oldQuantity.compareTo(newQuantity) != 0 || !sameStockKey) {
        // å¦‚果发生领料重提,补写一条新的正常领料流水。
        if (needReissuePickRecord) {
            insertPickRecord(oldPick.getId(),
                    dto.getProductionOrderId(),
                    dto.getProductionOperationTaskId(),
                    newProductModelId,
                    newBatchNo,
                    recordQuantity,
                    oldQuantity,
                    newQuantity,
                    BigDecimal.ZERO,
                    newQuantity,
                    dto.getPickType(),
                    dto.getRemark(),
@@ -444,6 +549,7 @@
                                  Byte pickType,
                                  String remark,
                                  String feedingReason) {
        // å†™é¢†æ–™æµæ°´è®°å½•:统一记录领料/补料/退料数量变化轨迹。
        ProductionOrderPickRecord pickRecord = new ProductionOrderPickRecord();
        pickRecord.setPickId(pickId);
        pickRecord.setProductionOrderId(productionOrderId);
@@ -459,8 +565,31 @@
        productionOrderPickRecordMapper.insert(pickRecord);
    }
    private void subtractInventory(Long productModelId, String batchNo, BigDecimal quantity, int rowNo) {
    private void deleteNormalPickRecord(Long pickId) {
        // åˆ é™¤è¯¥é¢†æ–™å•历史上的“正常领料”流水,保留补料/退料流水。
        if (pickId == null) {
            return;
        }
        productionOrderPickRecordMapper.delete(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getPickId, pickId)
                        .eq(ProductionOrderPickRecord::getPickType, PICK_TYPE_NORMAL)
        );
    }
    private void subtractInventory(Long recordId,
                                   Long productModelId,
                                   String batchNo,
                                   BigDecimal quantity,
                                   int rowNo,
                                   String stockOutRecordType) {
        // æ‰£å‡åº“存总流程:
        // 1) è§£æžæ‰¹æ¬¡åˆ—表;
        // 2) è®¡ç®—每个批次可用量与总可用量;
        // 3) æŒ‰æ‰¹æ¬¡é¡ºåºé€ç¬”“新增出库记录并审批通过”,直到扣完目标数量;
        // 4) ä»»ä¸€æ­¥å¤±è´¥å³æŠ›é”™å¹¶å›žæ»šäº‹åŠ¡ã€‚
        BigDecimal deductQuantity = defaultDecimal(quantity);
        // é¢†æ–™æ•°é‡å°äºŽç­‰äºŽ0时,不需要执行库存扣减。
        if (deductQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
@@ -470,9 +599,12 @@
            batchNoList = Collections.singletonList(null);
        }
        // å…ˆè®¡ç®—各批次可用量,避免边扣边算导致判断不一致。
        Map<String, BigDecimal> availableQuantityMap = new LinkedHashMap<>();
        BigDecimal totalAvailableQuantity = BigDecimal.ZERO;
        // éåŽ†æ‰¹æ¬¡ï¼Œè®¡ç®—æ¯ä¸ªæ‰¹æ¬¡å¯ç”¨åº“å­˜ã€‚
        for (String currentBatchNo : batchNoList) {
            // æŸ¥è¯¢å½“前规格+批次的库存记录。
            StockInventory stockInventory = stockInventoryMapper.selectOne(buildStockWrapper(productModelId, currentBatchNo));
            BigDecimal availableQuantity = BigDecimal.ZERO;
            if (stockInventory != null) {
@@ -488,10 +620,11 @@
        if (deductQuantity.compareTo(totalAvailableQuantity) > 0) {
            BigDecimal shortQuantity = deductQuantity.subtract(totalAvailableQuantity);
            throw new ServiceException("领料可用库存不足,可用库存为" + formatQuantity(totalAvailableQuantity)
                    + ",还差" + formatQuantity(shortQuantity));
            throw new ServiceException("第" + rowNo + "行扣减库存失败:可用库存不足,当前可用"
                    + formatQuantity(totalAvailableQuantity) + ",仍缺少" + formatQuantity(shortQuantity));
        }
        // æŒ‰æ‰¹æ¬¡é¡ºåºé€ç¬”扣减库存。
        BigDecimal remainingQuantity = deductQuantity;
        for (Map.Entry<String, BigDecimal> entry : availableQuantityMap.entrySet()) {
            if (remainingQuantity.compareTo(BigDecimal.ZERO) <= 0) {
@@ -502,39 +635,137 @@
                continue;
            }
            BigDecimal currentDeductQuantity = remainingQuantity.min(availableQuantity);
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(entry.getKey());
            stockInventoryDto.setQualitity(currentDeductQuantity);
            int affected = stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto);
            if (affected <= 0) {
                throw new ServiceException("第" + rowNo + "条领料扣减库存失败");
            }
            createAndApproveStockOutRecord(recordId, productModelId, entry.getKey(), currentDeductQuantity, rowNo, stockOutRecordType);
            remainingQuantity = remainingQuantity.subtract(currentDeductQuantity);
        }
        if (remainingQuantity.compareTo(BigDecimal.ZERO) > 0) {
            throw new ServiceException("第" + rowNo + "条领料扣减库存失败,剩余待扣减数量为" + formatQuantity(remainingQuantity));
            throw new ServiceException("第" + rowNo + "行扣减库存失败:仍有未扣减数量" + formatQuantity(remainingQuantity));
        }
    }
    private void addInventory(Long productModelId, String batchNo, BigDecimal quantity) {
    private void createAndApproveStockOutRecord(Long recordId,
                                                Long productModelId,
                                                String batchNo,
                                                BigDecimal quantity,
                                                int rowNo,
                                                String stockOutRecordType) {
        // åº“存扣减改为两步:
        // 1) å…ˆè°ƒç”¨ addStockOutRecordOnly æ–°å¢žå¾…审批出库记录;
        // 2) å†è°ƒç”¨å‡ºåº“审批,审批状态固定传 1(通过)。
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryDto.setRecordType(stockOutRecordType);
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(quantity);
            stockInventoryService.addStockOutRecordOnly(stockInventoryDto);
            LambdaQueryWrapper<StockOutRecord> recordWrapper = Wrappers.<StockOutRecord>lambdaQuery()
                    .eq(StockOutRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockOutRecord::getRecordType, stockOutRecordType)
                    .eq(StockOutRecord::getProductModelId, productModelId)
                    .eq(StockOutRecord::getType, "0")
                    .orderByDesc(StockOutRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockOutRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockOutRecord::getBatchNo, batchNo);
            }
            StockOutRecord stockOutRecord = stockOutRecordService.getOne(recordWrapper, false);
            if (stockOutRecord == null || stockOutRecord.getId() == null) {
                throw new ServiceException("第" + rowNo + "行扣减库存失败:未找到对应出库申请记录");
            }
            stockOutRecordService.batchApprove(
                    Collections.singletonList(stockOutRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("第" + rowNo + "行扣减库存失败:" + ex.getMessage());
        }
    }
    private void addInventory(Long recordId,
                              Long productModelId,
                              String batchNo,
                              BigDecimal quantity,
                              String stockInRecordType) {
        // å›žè¡¥åº“存改为两步:
        // 1) å…ˆæ–°å¢žå…¥åº“申请;
        // 2) å†å®¡æ‰¹é€šè¿‡ï¼Œç¡®ä¿åº“存立刻回补生效。
        BigDecimal addQuantity = defaultDecimal(quantity);
        if (addQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        try {
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setProductModelId(productModelId);
        stockInventoryDto.setBatchNo(batchNo);
        stockInventoryDto.setQualitity(addQuantity);
        stockInventoryDto.setRecordType(String.valueOf(StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode()));
        stockInventoryDto.setRecordId(0L);
            stockInventoryDto.setRecordType(stockInRecordType);
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
        stockInventoryService.addStockInRecordOnly(stockInventoryDto);
            LambdaQueryWrapper<StockInRecord> recordWrapper = Wrappers.<StockInRecord>lambdaQuery()
                    .eq(StockInRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockInRecord::getRecordType, stockInventoryDto.getRecordType())
                    .eq(StockInRecord::getProductModelId, productModelId)
                    .eq(StockInRecord::getType, "0")
                    .orderByDesc(StockInRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockInRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockInRecord::getBatchNo, batchNo);
            }
            StockInRecord stockInRecord = stockInRecordService.getOne(recordWrapper, false);
            if (stockInRecord == null || stockInRecord.getId() == null) {
                throw new ServiceException("回补库存失败:未找到对应入库申请记录");
            }
            stockInRecordService.batchApprove(
                    Collections.singletonList(stockInRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("回补库存失败:" + ex.getMessage());
        }
    }
    private void addInventoryRecordOnly(Long recordId,
                                        Long productModelId,
                                        String batchNo,
                                        BigDecimal quantity,
                                        String stockInRecordType) {
        // ä»…记录入库申请,不做审核通过。
        BigDecimal addQuantity = defaultDecimal(quantity);
        if (addQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(addQuantity);
            stockInventoryDto.setRecordType(stockInRecordType);
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("退料入库记录保存失败:" + ex.getMessage());
        }
    }
    private List<ProductionOrderPickDto> resolvePickItems(ProductionOrderPickDto dto) {
        // è§£æžæ–°å¢žåœºæ™¯çš„领料明细集合。
        if (dto == null) {
            throw new ServiceException("领料参数不能为空");
            throw new ServiceException("参数不能为空");
        }
        if (dto.getPickList() != null && !dto.getPickList().isEmpty()) {
            return dto.getPickList();
@@ -543,6 +774,7 @@
    }
    private List<ProductionOrderPickDto> resolveUpdateItems(ProductionOrderPickDto dto) {
        // è§£æžæ›´æ–°åœºæ™¯çš„领料明细集合。
        if (dto.getPickList() != null) {
            return dto.getPickList();
        }
@@ -553,6 +785,7 @@
    }
    private boolean isEmptyUpdateItem(ProductionOrderPickDto dto) {
        // åˆ¤æ–­æ›´æ–°è¡Œæ˜¯å¦ä¸ºç©ºç™½å ä½è¡Œã€‚
        return dto.getId() == null
                && dto.getProductModelId() == null
                && dto.getPickQuantity() == null
@@ -573,6 +806,7 @@
    }
    private Long resolveProductionOrderId(ProductionOrderPickDto dto) {
        // ä¼˜å…ˆä»Žä¸»DTO解析订单ID,不存在时再从子项中回退查找。
        if (dto.getProductionOrderId() != null) {
            return dto.getProductionOrderId();
        }
@@ -588,7 +822,12 @@
    }
    private ProductionOrderPickDto mergeDto(ProductionOrderPickDto rootDto, ProductionOrderPickDto itemDto) {
        // åˆå¹¶è§„则:
        // - itemDto ä¼˜å…ˆæ‰¿è½½è¡Œçº§è¾“入;
        // - itemDto ç¼ºå¤±å­—段从 rootDto å…œåº•继承;
        // - è¾“出 merged ä½œä¸ºç»Ÿä¸€ä¸šåŠ¡å…¥å‚ã€‚
        ProductionOrderPickDto merged = new ProductionOrderPickDto();
        // å…ˆæ‹·è´è¡Œçº§å­—段。
        if (itemDto != null) {
            merged.setId(itemDto.getId());
            merged.setProductionOrderId(itemDto.getProductionOrderId());
@@ -667,51 +906,52 @@
    }
    private void validatePickParam(ProductionOrderPickDto dto, int rowNo) {
        // æ ¡éªŒæ™®é€šé¢†æ–™å‚数(订单、规格、数量、类型)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "条生产订单ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getProductModelId() == null) {
            throw new ServiceException("第" + rowNo + "条产品规格ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:产品规格不能为空");
        }
        if (dto.getPickQuantity() == null || dto.getPickQuantity().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条领料数量不能小于0");
            throw new ServiceException("第" + rowNo + "行参数错误:领料数量不能小于0");
        }
        if (dto.getPickType() != null && dto.getPickType() != PICK_TYPE_NORMAL && dto.getPickType() != PICK_TYPE_FEEDING) {
            throw new ServiceException("第" + rowNo + "条领料类型只能是1或2");
            throw new ServiceException("第" + rowNo + "行参数错误:领料类型仅支持1(领料)或2(补料)");
        }
    }
    private void validateFeedingParam(ProductionOrderPickDto dto, int rowNo) {
        // æ ¡éªŒè¡¥æ–™å‚数(订单、领料ID、补料数量、类型)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "条生产订单ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getId() == null) {
            throw new ServiceException("第" + rowNo + "条领料ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:领料记录ID不能为空");
        }
        if (dto.getFeedingQuantity() == null || dto.getFeedingQuantity().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条本次补料数量不能小于0");
            throw new ServiceException("第" + rowNo + "行参数错误:补料数量不能小于0");
        }
        if (!isFeedingPick(dto)) {
            throw new ServiceException("第" + rowNo + "条补料类型必须为2");
            throw new ServiceException("第" + rowNo + "行参数错误:补料场景下领料类型必须为2");
        }
    }
    private void validateReturnParam(ProductionOrderPickDto dto, int rowNo) {
        // æ ¡éªŒé€€æ–™å‚数(订单、领料ID、退料量)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "条生产订单ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getId() == null) {
            throw new ServiceException("第" + rowNo + "条领料ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:领料记录ID不能为空");
        }
        if (dto.getReturnQty() == null || dto.getReturnQty().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条退料数量不能为空且不能小于0");
        }
        if (dto.getActualQty() == null || dto.getActualQty().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条实际数量不能为空且不能小于0");
            throw new ServiceException("第" + rowNo + "行参数错误:退料数量不能小于0");
        }
    }
    private boolean isFeedingRequest(ProductionOrderPickDto dto) {
        // åˆ¤æ–­å½“前请求是否属于补料流程。
        if (isFeedingPick(dto)) {
            return true;
        }
@@ -724,10 +964,12 @@
    }
    private boolean isFeedingPick(ProductionOrderPickDto dto) {
        // åˆ¤æ–­å½“前行是否为补料类型。
        return dto != null && Objects.equals(dto.getPickType(), PICK_TYPE_FEEDING);
    }
    private boolean isReturnRequest(ProductionOrderPickDto dto) {
        // åˆ¤æ–­å½“前请求是否属于退料流程。
        if (isReturnPick(dto)) {
            return true;
        }
@@ -740,10 +982,12 @@
    }
    private boolean isReturnPick(ProductionOrderPickDto dto) {
        // åˆ¤æ–­å½“前行是否为退料类型。
        return dto != null && Boolean.TRUE.equals(dto.getReturned());
    }
    private BigDecimal sumFeedingQuantity(Long productionOrderId, Long pickId) {
        // æ±‡æ€»æŒ‡å®šé¢†æ–™å•的历史补料总量。
        List<ProductionOrderPickRecord> feedingRecords = productionOrderPickRecordMapper.selectList(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getProductionOrderId, productionOrderId)
@@ -756,12 +1000,14 @@
    }
    private BigDecimal calculateActualQty(ProductionOrderPick pick, BigDecimal feedingQty) {
        // æŒ‰â€œé¢†æ–™+补料-退料”计算实际用量。
        return defaultDecimal(pick.getQuantity())
                .add(defaultDecimal(feedingQty))
                .subtract(defaultDecimal(pick.getReturnQty()));
    }
    private String normalizeBatchNo(String batchNo) {
        // æ ‡å‡†åŒ–批次号(去空白、空串转null)。
        if (StringUtils.isEmpty(batchNo)) {
            return null;
        }
@@ -769,6 +1015,7 @@
        return trimBatchNo.isEmpty() ? null : trimBatchNo;
    }
    private List<String> resolveBatchNoList(ProductionOrderPickDto dto) {
        // ä¼˜å…ˆè§£æž batchNoList,空则回退解析 batchNo å­—符串。
        List<String> normalizedBatchNoList = normalizeBatchNoList(dto.getBatchNoList());
        if (!normalizedBatchNoList.isEmpty()) {
            return normalizedBatchNoList;
@@ -777,6 +1024,7 @@
    }
    private String pickInventoryBatchNo(List<String> batchNoList) {
        // ä»Žæ‰¹æ¬¡é›†åˆä¸­å–库存扣减使用的批次。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return null;
        }
@@ -784,10 +1032,12 @@
    }
    private String resolveInventoryBatchNoFromStored(String storedBatchNo) {
        // ä»Žæ•°æ®åº“存储批次字段中反解可用批次。
        return pickInventoryBatchNo(parseBatchNoValue(storedBatchNo));
    }
    private String formatBatchNoStorage(List<String> batchNoList) {
        // å°†æ‰¹æ¬¡é›†åˆæ ¼å¼åŒ–为数据库存储值。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return null;
        }
@@ -798,6 +1048,7 @@
    }
    private List<String> normalizeBatchNoList(List<String> batchNoList) {
        // æ‰¹é‡æ ‡å‡†åŒ–批次号并去重。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return Collections.emptyList();
        }
@@ -812,6 +1063,7 @@
    }
    private void fillBatchNoList(List<ProductionOrderPickVo> detailList) {
        // å°†åŒè®¢å•+同规格+同工序的数据按组聚合批次,便于前端统一展示。
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
@@ -832,9 +1084,11 @@
    }
    private void fillSelectableBatchNoList(List<ProductionOrderPickVo> detailList) {
        // åˆå¹¶â€œå·²é€‰æ‰¹æ¬¡â€å’Œâ€œåº“存可选批次”,用于前端下拉。
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        // å…ˆæ”¶é›†æ˜Žç»†ä¸­æ¶‰åŠçš„è§„æ ¼ID,批量查询库存批次。
        Set<Long> productModelIdSet = detailList.stream()
                .map(ProductionOrderPickVo::getProductModelId)
                .filter(Objects::nonNull)
@@ -868,30 +1122,35 @@
    }
    private String buildBatchNoGroupKey(ProductionOrderPickVo detail) {
        return String.valueOf(detail.getProductionOrderId()) + "|"
                + String.valueOf(detail.getProductModelId()) + "|"
                + String.valueOf(detail.getTechnologyOperationId()) + "|"
                + String.valueOf(detail.getOperationName());
        // æž„建批次聚合分组键。
        return detail.getProductionOrderId() + "|"
                + detail.getProductModelId() + "|"
                + detail.getTechnologyOperationId() + "|"
                + detail.getOperationName();
    }
    private List<String> parseBatchNoValue(String rawBatchNoValue) {
        // æ‰¹æ¬¡è§£æžå…¼å®¹ä¸‰ç§æ ¼å¼ï¼š
        // 1) å•值:A001
        // 2) é€—号分隔:A001,A002
        // 3) ç±»JSON数组字符串:["A001","A002"]
        String normalizedValue = normalizeBatchNo(rawBatchNoValue);
        if (StringUtils.isEmpty(normalizedValue)) {
            return Collections.emptyList();
        }
        if (normalizedValue.startsWith("[") && normalizedValue.endsWith("]")) {
        if (normalizedValue != null && normalizedValue.startsWith("[") && normalizedValue.endsWith("]")) {
            String value = normalizedValue.substring(1, normalizedValue.length() - 1);
            if (StringUtils.isEmpty(value)) {
                return Collections.emptyList();
            }
            List<String> parsed = Arrays.stream(value.split(","))
                    .map(item -> item == null ? null : item.trim().replace("\"", "").replace("'", ""))
                    .map(item -> item.trim().replace("\"", "").replace("'", ""))
                    .collect(Collectors.toList());
            return normalizeBatchNoList(parsed);
        }
        if (normalizedValue.contains(",")) {
        if (normalizedValue != null && normalizedValue.contains(",")) {
            List<String> parsed = Arrays.stream(normalizedValue.split(","))
                    .map(item -> item == null ? null : item.trim())
                    .map(item -> item.trim())
                    .collect(Collectors.toList());
            return normalizeBatchNoList(parsed);
        }
@@ -899,6 +1158,7 @@
    }
    private LambdaQueryWrapper<StockInventory> buildStockWrapper(Long productModelId, String batchNo) {
        // æž„建库存查询条件(规格 + æ‰¹æ¬¡ï¼‰ã€‚
        LambdaQueryWrapper<StockInventory> wrapper = Wrappers.<StockInventory>lambdaQuery()
                .eq(StockInventory::getProductModelId, productModelId);
        if (StringUtils.isEmpty(batchNo)) {
@@ -910,10 +1170,12 @@
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        // BigDecimal ç©ºå€¼å…œåº•,统一按0处理。
        return value == null ? BigDecimal.ZERO : value;
    }
    private String formatQuantity(BigDecimal value) {
        // æ•°é‡æ ¼å¼åŒ–输出(去除末尾无效0)。
        return defaultDecimal(value).stripTrailingZeros().toPlainString();
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingOperationParamServiceImpl.java
@@ -48,17 +48,20 @@
    @Override
    public List<ProductionOrderRoutingOperationParamVo> listProductionOrderRoutingOperationParam(ProductionOrderRoutingOperationParamDto dto) {
        // æŸ¥è¯¢ç”Ÿäº§è®¢å•工艺路线工序参数
        return BeanUtil.copyToList(this.list(buildQueryWrapper(dto)), ProductionOrderRoutingOperationParamVo.class);
    }
    @Override
    public ProductionOrderRoutingOperationParamVo getProductionOrderRoutingOperationParamInfo(Long id) {
        // èŽ·å–ç”Ÿäº§è®¢å•å·¥è‰ºè·¯çº¿å·¥åºå‚æ•°è¯¦æƒ…
        ProductionOrderRoutingOperationParam item = this.getById(id);
        return item == null ? null : BeanUtil.copyProperties(item, ProductionOrderRoutingOperationParamVo.class);
    }
    @Override
    public boolean saveProductionOrderRoutingOperationParam(ProductionOrderRoutingOperationParam item) {
        // ä¿å­˜ç”Ÿäº§è®¢å•工艺路线工序参数
        ProductionOrderRoutingOperation routingOperation = getRoutingOperation(item.getProductionOrderRoutingOperationId());
        fillFromSourceParam(item, routingOperation);
        validateManualFields(item);
@@ -68,10 +71,12 @@
    @Override
    public boolean removeProductionOrderRoutingOperationParam(Long id) {
        // åˆ é™¤ç”Ÿäº§è®¢å•工艺路线工序参数
        return this.removeById(id);
    }
    private LambdaQueryWrapper<ProductionOrderRoutingOperationParam> buildQueryWrapper(ProductionOrderRoutingOperationParamDto dto) {
        // æŒ‰æ¡ä»¶åŠ¨æ€æž„å»ºæ•°æ®åº“æŸ¥è¯¢æ¡ä»¶
        ProductionOrderRoutingOperationParam query = dto == null ? new ProductionOrderRoutingOperationParam() : dto;
        return Wrappers.<ProductionOrderRoutingOperationParam>lambdaQuery()
                .eq(query.getId() != null, ProductionOrderRoutingOperationParam::getId, query.getId())
@@ -96,29 +101,31 @@
    }
    private ProductionOrderRoutingOperation getRoutingOperation(Long productionOrderRoutingOperationId) {
        // èŽ·å–å·¥è‰ºè·¯çº¿å·¥åº
        if (productionOrderRoutingOperationId == null) {
            throw new ServiceException("productionOrderRoutingOperationId is required");
            throw new ServiceException("生产订单工艺路线工序ID不能为空");
        }
        ProductionOrderRoutingOperation routingOperation = productionOrderRoutingOperationMapper.selectById(productionOrderRoutingOperationId);
        if (routingOperation == null) {
            throw new ServiceException("Production order routing operation not found");
            throw new ServiceException("生产订单工艺路线工序不存在");
        }
        return routingOperation;
    }
    private void fillFromSourceParam(ProductionOrderRoutingOperationParam item, ProductionOrderRoutingOperation routingOperation) {
        // ä»Žæ¥æºå‚数回填当前参数默认值
        item.setProductionOrderId(routingOperation.getProductionOrderId());
        item.setProductionOrderRoutingOperationId(routingOperation.getId());
        ProductionOrder productionOrder = productionOrderMapper.selectById(routingOperation.getProductionOrderId());
        if (productionOrder == null) {
            throw new ServiceException("Production order not found");
            throw new ServiceException("生产订单不存在");
        }
        if (item.getParamId() == null) {
            return;
        }
        TechnologyParam sourceParam = technologyParamMapper.selectById(item.getParamId());
        if (sourceParam == null) {
            throw new ServiceException("Technology  param not found");
            throw new ServiceException("工艺参数不存在");
        }
        if (item.getTechnologyOperationParamId() != null) {
            TechnologyRoutingOperationParam sourceRoutingOperationParam = technologyRoutingOperationParamMapper.selectById(item.getTechnologyOperationParamId());
@@ -141,15 +148,17 @@
    }
    private void validateManualFields(ProductionOrderRoutingOperationParam item) {
        // æ ¡éªŒæ‰‹å·¥å½•入字段的必填与格式
        if (item.getParamCode() == null || item.getParamCode().trim().isEmpty()) {
            throw new ServiceException("paramCode is required");
            throw new ServiceException("参数编码不能为空");
        }
        if (item.getParamName() == null || item.getParamName().trim().isEmpty()) {
            throw new ServiceException("paramName is required");
            throw new ServiceException("参数名称不能为空");
        }
    }
    private void checkDuplicate(ProductionOrderRoutingOperationParam item) {
        // æ£€æŸ¥æ•°æ®æ˜¯å¦é‡å¤ï¼Œé¿å…é‡å¤ä¿å­˜
        boolean duplicate = productionOrderRoutingOperationParamMapper.selectCount(
                Wrappers.<ProductionOrderRoutingOperationParam>lambdaQuery()
                        .isNull(ProductionOrderRoutingOperationParam::getProductionProductMainId)
@@ -161,7 +170,7 @@
                        .ne(item.getId() != null, ProductionOrderRoutingOperationParam::getId, item.getId())
        ) > 0;
        if (duplicate) {
            throw new ServiceException("Duplicate production order routing operation param");
            throw new ServiceException("生产订单工艺路线工序参数重复");
        }
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingOperationServiceImpl.java
@@ -45,12 +45,15 @@
    @Override
    public R addRouteItem(ProductionOrderRoutingOperation productionOrderRoutingOperation) {
        // æ–°å¢žå·¥è‰ºè·¯çº¿
        int insert = productionOrderRoutingOperationMapper.insert(productionOrderRoutingOperation);
        //工序关联的参数需要同步新增
        List<TechnologyOperationParam> technologyOperationParams = technologyOperationParamMapper.selectList(Wrappers.<TechnologyOperationParam>lambdaQuery()
                .eq(TechnologyOperationParam::getTechnologyOperationId, productionOrderRoutingOperation.getTechnologyOperationId()));
        // å‚数与前置条件校验
        if (CollectionUtils.isNotEmpty(technologyOperationParams)){
            ArrayList<ProductionOrderRoutingOperationParam> productionOrderRoutingOperationParams = new ArrayList<>();
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
            for (TechnologyOperationParam technologyOperationParam : technologyOperationParams) {
                TechnologyParam technologyParam = technologyParamMapper.selectById(technologyOperationParam.getTechnologyParamId());
                ProductionOrderRoutingOperationParam productionOrderRoutingOperationParam = new ProductionOrderRoutingOperationParam();
@@ -103,11 +106,14 @@
    @Override
    public R deleteRouteItem(Long id) {
        // åˆ é™¤å·¥è‰ºè·¯çº¿
        try {
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
            ProductionOperationTask productionOperationTask = productionOperationTaskMapper.selectOne(
                    new LambdaQueryWrapper<ProductionOperationTask>()
                            .eq(ProductionOperationTask::getProductionOrderRoutingOperationId, id)
                            .last("limit 1"));
        // å‚数与前置条件校验
            if (productionOperationTask == null) {
                throw new RuntimeException("删除失败:未找到关联的生产工单");
            }
@@ -118,6 +124,7 @@
            List<ProductionProductMain> productionProductMains = productionProductMainMapper.selectList(
                    new LambdaQueryWrapper<ProductionProductMain>()
                            .eq(ProductionProductMain::getProductionOperationTaskId, productionOperationTask.getId()));
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
            for (ProductionProductMain main : productionProductMains) {
                productionProductMainService.removeProductMain(main.getId());
            }
@@ -140,6 +147,7 @@
                    ProductionOrderRoutingOperation item = operationList.get(i);
                    if (!Integer.valueOf(i + 1).equals(item.getDragSort())) {
                        item.setDragSort(i + 1);
        // æŒä¹…化或输出处理结果
                        productionOrderRoutingOperationMapper.updateById(item);
                    }
                }
@@ -152,6 +160,7 @@
    @Override
    public int sortRouteItem(ProductionOrderRoutingOperation productionOrderRoutingOperation) {
        // æŽ’序工艺路线
        ProductionOrderRoutingOperation oldItem = productionOrderRoutingOperationMapper.selectById(productionOrderRoutingOperation.getId());
        List<ProductionOrderRoutingOperation> operationList = productionOrderRoutingOperationMapper.selectList(
                Wrappers.<ProductionOrderRoutingOperation>lambdaQuery()
src/main/java/com/ruoyi/production/service/impl/ProductionOrderRoutingServiceImpl.java
@@ -21,6 +21,7 @@
    @Override
    public ProductionOrderRouting listMain(Long orderId) {
        // æŸ¥è¯¢ä¸»è¡¨ID集合
        return productionOrderRoutingMapper.selectOne(
                Wrappers.<ProductionOrderRouting>lambdaQuery()
                        .eq(ProductionOrderRouting::getProductionOrderId, orderId)
@@ -30,6 +31,7 @@
    @Override
    public List<ProductionOrderRoutingOperationVo> listItem(Long orderId) {
        // æŸ¥è¯¢å·¥è‰ºè·¯çº¿å·¥åºæ˜Žç»†
        return productionOrderRoutingOperationMapper.selectVoListByOrderId(orderId);
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionOrderServiceImpl.java
@@ -82,6 +82,7 @@
    @Override
    public IPage<ProductionOrderVo> pageProductionOrder(Page<ProductionOrderDto> page, ProductionOrderDto dto) {
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§è®¢å•
        Page<ProductionOrderVo> result = (Page<ProductionOrderVo>) baseMapper.pageProductionOrder(page, dto);
        fillProductImages(result.getRecords());
        return result;
@@ -89,6 +90,7 @@
    @Override
    public List<ProductionOrderVo> listProductionOrder(ProductionOrderDto dto) {
        // æŸ¥è¯¢ç”Ÿäº§è®¢å•列表
        List<ProductionOrderVo> records = baseMapper.listProductionOrder(dto);
        fillProductImages(records);
        return records;
@@ -96,6 +98,7 @@
    @Override
    public ProductionOrderVo getProductionOrderInfo(Long id) {
        // èŽ·å–ç”Ÿäº§è®¢å•è¯¦æƒ…
        ProductionOrderVo item = baseMapper.getProductionOrderInfo(id);
        if (item == null) {
            return null;
@@ -106,6 +109,7 @@
    @Override
    public boolean saveProductionOrder(ProductionOrder productionOrder) {
        // ä¿å­˜ç”Ÿäº§è®¢å•
        ProductionOrder oldOrder = productionOrder.getId() == null ? null : this.getById(productionOrder.getId());
        // ä¸‹å•入口统一补齐来源单据、计划和工艺信息,避免前端分别传多套字段。
        validateAndFillOrder(productionOrder, oldOrder);
@@ -137,6 +141,7 @@
    @Override
    public boolean removeProductionOrder(List<Long> ids) {
        // åˆ é™¤ç”Ÿäº§è®¢å•
        if (ids == null || ids.isEmpty()) {
            return false;
        }
@@ -150,6 +155,7 @@
    @Override
    public Integer bindingRoute(ProductionOrderDto productionOrderDto) {
        // ä¸ºè®¢å•绑定工艺路线
        if (productionOrderDto == null || productionOrderDto.getId() == null) {
            throw new ServiceException("生产订单ID不能为空");
        }
@@ -193,6 +199,7 @@
    @Override
    public List<ProductionPlanVo> getSource(Long id) {
        // æŸ¥è¯¢è®¢å•关联来源计划
        ProductionOrder productionOrder = baseMapper.selectById(id);
        if (productionOrder != null && productionOrder.getProductionPlanIds() != null) {
            List<Long> planIds = parsePlanIds(productionOrder.getProductionPlanIds());
@@ -203,7 +210,9 @@
    @Override
    public int syncProductionOrderSnapshot(Long productionOrderId) {
        // åŒæ­¥è®¢å•工艺、工序、参数和BOM快照
        ProductionOrder productionOrder = this.getById(productionOrderId);
        // å‚数与前置条件校验
        if (productionOrder == null) {
            throw new ServiceException("生产订单不存在");
        }
@@ -218,6 +227,7 @@
        clearProductionSnapshot(productionOrderId);
        ProductionOrderBom orderBom = syncProductionOrderBomSnapshot(productionOrder, technologyRouting);
        //生产订单工艺路线表
        ProductionOrderRouting orderRouting = new ProductionOrderRouting();
        orderRouting.setProductionOrderId(productionOrder.getId());
        orderRouting.setTechnologyRoutingId(technologyRouting.getId());
@@ -229,12 +239,14 @@
        productionOrderRoutingMapper.insert(orderRouting);
        int syncedParamCount = 0;
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        List<TechnologyRoutingOperation> routingOperations = technologyRoutingOperationMapper.selectList(
                Wrappers.<TechnologyRoutingOperation>lambdaQuery()
                        .eq(TechnologyRoutingOperation::getTechnologyRoutingId, technologyRouting.getId())
                        .orderByDesc(TechnologyRoutingOperation::getDragSort)
                        .orderByDesc(TechnologyRoutingOperation::getId));
        Map<Long, String> operationNameMap = technologyOperationMapper.selectBatchIds(
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
                        routingOperations.stream()
                                .map(TechnologyRoutingOperation::getTechnologyOperationId)
                                .filter(Objects::nonNull)
@@ -258,6 +270,7 @@
            targetOperation.setIsQuality(sourceOperation.getIsQuality());
            targetOperation.setOperationName(operationNameMap.get(sourceOperation.getTechnologyOperationId()));
            targetOperation.setTechnologyOperationId(sourceOperation.getTechnologyOperationId());
            targetOperation.setType(sourceOperation.getType());
            productionOrderRoutingOperationMapper.insert(targetOperation);
            boolean isLastOperation = lastDragSort != null && Objects.equals(sourceOperation.getDragSort(), lastDragSort);
@@ -301,6 +314,7 @@
    }
    private ProductionOrderBom syncProductionOrderBomSnapshot(ProductionOrder productionOrder, TechnologyRouting technologyRouting) {
        // åŒæ­¥è®¢å•BOM快照结构
        if (technologyRouting.getBomId() == null) {
            return null;
        }
@@ -308,10 +322,12 @@
        if (technologyBom == null) {
            throw new ServiceException("工艺BOM不存在");
        }
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        List<TechnologyBomStructure> structureList = technologyBomStructureMapper.selectList(
                Wrappers.<TechnologyBomStructure>lambdaQuery()
                        .eq(TechnologyBomStructure::getBomId, technologyBom.getId())
                        .orderByAsc(TechnologyBomStructure::getId));
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        TechnologyBomStructure root = structureList.stream().filter(item -> item.getParentId() == null).findFirst().orElse(null);
        BigDecimal orderQuantity = defaultDecimal(productionOrder.getQuantity());
@@ -322,6 +338,7 @@
        orderBom.setRemark(technologyBom.getRemark());
        orderBom.setBomNo(technologyBom.getBomNo());
        orderBom.setVersion(technologyBom.getVersion());
        // æŒä¹…化或输出处理结果
        productionOrderBomMapper.insert(orderBom);
        Map<Long, Long> idMap = new HashMap<>();
@@ -343,16 +360,19 @@
    }
    private void clearProductionSnapshot(Long productionOrderId) {
        // å·²äº§ç”Ÿé¢†æ–™è®°å½•后禁止重建,避免备料/投料依据与订单快照脱节。
        // æ¸…理订单已生成的工艺与BOM快照数据
        boolean hasPickRecord = productionOrderPickRecordMapper.selectCount(
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getProductionOrderId, productionOrderId)) > 0;
        // å‚数与前置条件校验
        if (hasPickRecord) {
            throw new ServiceException("生产订单已存在领料记录,不能重新生成快照");
        }
        List<Long> taskIds = productionOperationTaskMapper.selectList(
                        Wrappers.<ProductionOperationTask>lambdaQuery()
                                .eq(ProductionOperationTask::getProductionOrderId, productionOrderId))
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
                .stream().map(ProductionOperationTask::getId).collect(Collectors.toList());
        if (!taskIds.isEmpty()) {
            // å·²æœ‰æŠ¥å·¥è®°å½•说明订单已开工,此时不允许再重建快照。
@@ -380,6 +400,7 @@
    }
    private LambdaQueryWrapper<ProductionOrder> buildQueryWrapper(ProductionOrderDto dto) {
        // æŒ‰æ¡ä»¶åŠ¨æ€æž„å»ºæ•°æ®åº“æŸ¥è¯¢æ¡ä»¶
        ProductionOrder query = dto == null ? new ProductionOrder() : dto;
        return Wrappers.<ProductionOrder>lambdaQuery()
                .eq(query.getId() != null, ProductionOrder::getId, query.getId())
@@ -390,6 +411,7 @@
    }
    private String generateNextOrderNo() {
        // ç”Ÿæˆä¸‹ä¸€ä¸ªç”Ÿäº§è®¢å•号
        String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        String prefix = "SC" + datePrefix;
        ProductionOrder latestOrder = this.getOne(Wrappers.<ProductionOrder>lambdaQuery()
@@ -408,6 +430,7 @@
    }
    private String generateNextTaskNo() {
        // ç”Ÿæˆä¸‹ä¸€ä¸ªç”Ÿäº§å·¥å•号
        String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        String prefix = "GD" + datePrefix;
        ProductionOperationTask lastTask = productionOperationTaskMapper.selectOne(
@@ -427,10 +450,12 @@
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        // å°†ç©ºæ•°é‡å…œåº•为0,避免空指针异常
        return value == null ? BigDecimal.ZERO : value;
    }
    private void validateAndFillOrder(ProductionOrder productionOrder, ProductionOrder oldOrder) {
        // æ ¡éªŒè®¢å•参数并补齐默认值
        if (productionOrder == null) {
            throw new ServiceException("生产订单不能为空");
        }
@@ -463,7 +488,9 @@
    }
    private void fillFromProductionPlans(ProductionOrder productionOrder) {
        // ä»Žå…³è”生产计划回填订单关键字段
        List<Long> planIds = parsePlanIds(productionOrder.getProductionPlanIds());
        // å‚数与前置条件校验
        if (planIds.isEmpty()) {
            return;
        }
@@ -472,6 +499,7 @@
        if (productionPlans.size() != planIds.size()) {
            throw new ServiceException("部分生产计划不存在");
        }
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        Map<Long, ProductionPlan> planMap = productionPlans.stream()
                .collect(Collectors.toMap(ProductionPlan::getId, item -> item, (left, right) -> left));
        ProductionPlan mainPlan = planMap.get(planIds.get(0));
@@ -510,6 +538,7 @@
    }
    private void releaseProductionPlanIssueStatus(ProductionOrder productionOrder) {
        // å›žé€€ç”Ÿäº§è®¡åˆ’下发状态
        if (productionOrder == null) {
            return;
        }
@@ -522,6 +551,7 @@
    //生产订单删除,生产计划的已下发数量对应变更
    private void updatePlanIssuedFlag(List<Long> planIds, BigDecimal remainingAssignedQuantity) {
        // æ›´æ–°è®¡åˆ’下发标记和下发数量
        if (planIds == null || planIds.isEmpty()) {
            return;
        }
@@ -551,6 +581,7 @@
    }
    private BigDecimal resolveRemainingQuantity(ProductionPlan plan) {
        // è®¡ç®—当前计划或记录的剩余数量
        if (plan == null) {
            return BigDecimal.ZERO;
        }
@@ -569,6 +600,7 @@
    }
    private int resolvePlanStatus(BigDecimal requiredQuantity, BigDecimal issuedQuantity) {
        // æ ¹æ®éœ€æ±‚量和下发量推导计划状态
        if (requiredQuantity == null || requiredQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return 0;
        }
@@ -579,6 +611,7 @@
    }
    private List<Long> parsePlanIds(String productionPlanIds) {
        // å°†è®¡åˆ’ID字符串解析为Long列表
        if (productionPlanIds == null || productionPlanIds.trim().isEmpty()) {
            return new ArrayList<>();
        }
@@ -595,6 +628,7 @@
    }
    private String formatPlanIds(List<Long> planIds) {
        // å°†è®¡åˆ’ID集合格式化为[1,2,3]字符串
        if (planIds == null || planIds.isEmpty()) {
            return null;
        }
@@ -605,6 +639,7 @@
    }
    private LocalDate resolvePlanCompleteDate(ProductionPlan productionPlan) {
        // è§£æžè®¡åˆ’完成日期
        if (productionPlan == null) {
            return null;
        }
@@ -618,13 +653,16 @@
    }
    private int compareDecimal(BigDecimal left, BigDecimal right) {
        // å®‰å…¨æ¯”较两个数量值大小
        return defaultDecimal(left).compareTo(defaultDecimal(right));
    }
    private void fillProductImages(List<ProductionOrderVo> records) {
        // å¡«å……产品图片
        if (records == null || records.isEmpty()) {
            return;
        }
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        List<Long> productModelIds = records.stream()
                .map(ProductionOrderVo::getProductModelId)
                .filter(Objects::nonNull)
@@ -634,6 +672,7 @@
            return;
        }
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        List<StorageAttachment> attachments = storageAttachmentMapper.selectList(
                Wrappers.<StorageAttachment>lambdaQuery()
                        .in(StorageAttachment::getRecordId, productModelIds)
@@ -676,6 +715,7 @@
    }
    private StorageBlobVO toStorageBlobVO(StorageBlob blob) {
        // å°†å­˜å‚¨æ–‡ä»¶å¯¹è±¡è½¬æ¢ä¸ºVO
        StorageBlobVO vo = BeanUtil.copyProperties(blob, StorageBlobVO.class);
        vo.setPreviewURL(fileUtil.buildSignedPreviewUrl(vo));
        vo.setDownloadURL(fileUtil.buildSignedDownloadUrl(vo));
@@ -684,8 +724,10 @@
    @Override
    public ProductionOrderWorkOrderDetailVo getWorkOrderReportInspectDetail(ProductionOrderDto dto) {
        // èŽ·å–å·¥å•è®¢å•æŠ¥å·¥è´¨æ£€æ˜Žç»†
        Long productionOrderId = resolveProductionOrderId(dto);
        ProductionOrderVo orderInfo = getProductionOrderInfo(productionOrderId);
        // å‚数与前置条件校验
        if (orderInfo == null) {
            throw new ServiceException("生产订单不存在");
        }
@@ -699,11 +741,12 @@
                new Page<ProductionOperationTaskVo>(1, -1), taskQuery);
        List<ProductionOperationTaskVo> workOrderList = workOrderPage == null || workOrderPage.getRecords() == null
                ? Collections.emptyList()
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
                : workOrderPage.getRecords().stream()
                .filter(Objects::nonNull)
                .sorted(Comparator.comparing(ProductionOperationTaskVo::getId, Comparator.nullsLast(Comparator.naturalOrder())))
                .collect(Collectors.toList());
        if (workOrderList == null || workOrderList.isEmpty()) {
                .toList();
        if (workOrderList.isEmpty()) {
            detailVo.setWorkOrderList(Collections.emptyList());
            return detailVo;
        }
@@ -714,6 +757,7 @@
                .collect(Collectors.toList());
        List<ProductionProductMain> reportMainList = workOrderIdList.isEmpty()
                ? Collections.emptyList()
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
                : productionProductMainMapper.selectList(
                Wrappers.<ProductionProductMain>lambdaQuery()
                        .in(ProductionProductMain::getProductionOperationTaskId, workOrderIdList)
@@ -824,6 +868,7 @@
                ProductionOrderWorkOrderDetailVo.ReportDetail reportDetail = new ProductionOrderWorkOrderDetailVo.ReportDetail();
                reportDetail.setReportMain(reportMain);
                reportDetail.setWorkHour(reportMain.getWorkHour());
                reportDetail.setReportOutputList(reportOutputMap.getOrDefault(reportMainId, Collections.emptyList()));
                reportDetail.setReportParamList(reportParamMap.getOrDefault(reportMainId, Collections.emptyList()));
                reportDetailList.add(reportDetail);
@@ -834,6 +879,7 @@
                    inspectDetail.setReportId(reportMainId);
                    inspectDetail.setReportNo(reportMain.getProductNo());
                    inspectDetail.setReportMain(reportMain);
                    inspectDetail.setWorkHour(reportMain.getWorkHour());
                    inspectDetail.setInspect(inspect);
                    inspectDetail.setInspectParamList(inspectParamMap.getOrDefault(inspect.getId(), Collections.emptyList()));
                    inspectDetail.setInspectFileList(inspectFileMap.getOrDefault(inspect.getId(), Collections.emptyList()));
@@ -851,6 +897,7 @@
    }
    private Long resolveProductionOrderId(ProductionOrderDto dto) {
        // ä»Žå…¥å‚中解析生产订单ID并校验
        if (dto == null) {
            throw new ServiceException("请传入生产订单ID或生产订单号");
        }
@@ -872,10 +919,12 @@
    @Override
    public List<ProductionOrderPickVo> pick(Long productionOrderId) {
        // æŸ¥è¯¢è®¢å•领料、投料与退料明细
        if (productionOrderId == null) {
            return Collections.emptyList();
        }
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        ProductionOrderBom orderBom = productionOrderBomMapper.selectOne(
                Wrappers.<ProductionOrderBom>lambdaQuery()
                        .eq(ProductionOrderBom::getProductionOrderId, productionOrderId)
@@ -890,6 +939,7 @@
            return Collections.emptyList();
        }
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        List<Long> productModelIds = bomStructureList.stream()
                .map(ProductionBomStructureVo::getProductModelId)
                .filter(Objects::nonNull)
@@ -946,6 +996,7 @@
    @Override
    public int updateOrder(ProductionOrderDto productionOrderDto) {
        // æ›´æ–°ç”Ÿäº§è®¢å•主数据
        productionOrderDto.setStatus(5);
        return baseMapper.updateById(productionOrderDto);
    }
src/main/java/com/ruoyi/production/service/impl/ProductionPlanServiceImpl.java
@@ -6,6 +6,10 @@
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.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.Product;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.StringUtils;
@@ -44,27 +48,32 @@
    private final ProductionPlanMapper productionPlanMapper;
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOrderService productionOrderService;
    private final ProductModelMapper productModelMapper;
    private final ProductMapper productMapper;
    @Override
    public IPage<ProductionPlanVo> listPage(Page<ProductionPlanDto> page, ProductionPlanDto productionPlanDto) {
        // åˆ†é¡µæŸ¥è¯¢ä¸»ç”Ÿäº§è®¡åˆ’列表
        return productionPlanMapper.listPage(page, productionPlanDto);
    }
    /**
     * åˆå¹¶ç”Ÿäº§è®¡åˆ’并下发生产订单。
     * ä¸šåŠ¡çº¦æŸï¼š
     * çº¦æŸï¼š
     * 1. ä»…允许同一产品型号的计划合并;
     * 2. å·²ä¸‹å‘或部分下发的计划禁止再次合并;
     * 3. ä¸‹å‘数量不能大于所选计划需求总量;
     * 4. è®¢å•创建统一复用 ProductionOrderService.saveProductionOrder,确保工艺/BOM/领料主单等后续逻辑一致。
     * 2. å·²ä¸‹å‘或部分下发的计划不允许再次合并;
     * 3. ä¸‹å‘数量不能大于所选计划剩余需求总量;
     * 4. ä¸‹å‘时统一调用 ProductionOrderService.saveProductionOrder,确保后续工艺/BOM/领料逻辑一致。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean combine(ProductionPlanDto productionPlanDto) {
        // åŸºç¡€å…¥å‚校验:没有可下发计划则直接返回 false
        if (productionPlanDto == null || productionPlanDto.getIds() == null || productionPlanDto.getIds().isEmpty()) {
            return false;
        }
        // åŽ»ç©ºã€åŽ»é‡ï¼Œå¾—åˆ°æœ¬æ¬¡å‚ä¸Žåˆå¹¶çš„è®¡åˆ’ ID
        List<Long> planIds = productionPlanDto.getIds().stream()
                .filter(Objects::nonNull)
                .distinct()
@@ -73,43 +82,52 @@
            throw new ServiceException("下发失败,未选择生产计划");
        }
        // æŸ¥è¯¢å¹¶æ ¡éªŒè®¡åˆ’是否都存在
        List<ProductionPlanDto> planLists = productionPlanMapper.selectWithMaterialByIds(planIds);
        if (planLists == null || planLists.isEmpty() || planLists.size() != planIds.size()) {
            throw new ServiceException("下发失败,生产计划不存在或已被删除");
        }
        // ä»¥ç¬¬ä¸€æ¡è®¡åˆ’作为型号基准
        ProductionPlanDto firstPlan = planLists.getFirst();
        if (firstPlan.getProductModelId() == null) {
            throw new ServiceException("下发失败,生产计划缺少产品型号");
        }
        // ä»…允许同型号计划合并下发
        boolean hasDifferentModel = planLists.stream()
                .anyMatch(item -> !Objects.equals(item.getProductModelId(), firstPlan.getProductModelId()));
        if (hasDifferentModel) {
            throw new BaseException("合并失败,所选生产计划的产品型号不一致");
        }
        boolean hasIssuedPlan = planLists.stream()
                .anyMatch(item -> item.getStatus() != null && item.getStatus() == PLAN_STATUS_ISSUED);
        if (hasIssuedPlan) {
            throw new BaseException("合并失败,所选生产计划存在已下发或部分下发数据");
        // ä»…“已下发”计划不允许再次参与合并下发;
        // â€œå¾…下发/部分下发”允许继续下发剩余数量。
        boolean hasFullyIssuedPlan = planLists.stream()
                .anyMatch(item -> item.getStatus() != null
                        && item.getStatus() == PLAN_STATUS_ISSUED);
        if (hasFullyIssuedPlan) {
            throw new BaseException("合并失败,所选生产计划存在已下发的数据");
        }
        // è®¡ç®—本次可下发的剩余需求总量
        BigDecimal totalRequiredQuantity = planLists.stream()
                .map(this::resolveRemainingQuantity)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        if (totalRequiredQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("下发失败,所选生产计划需求总量必须大于0");
            throw new ServiceException("下发失败,所选生产计划剩余需求总量必须大于0");
        }
        // æ ¡éªŒä¸‹å‘数量
        BigDecimal assignedQuantity = productionPlanDto.getTotalAssignedQuantity();
        if (assignedQuantity == null || assignedQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("下发失败,下发数量必须大于0");
        }
        if (assignedQuantity.compareTo(totalRequiredQuantity) > 0) {
            throw new ServiceException("下发失败,下发数量不能大于计划需求总量");
            throw new ServiceException("下发失败,下发数量不能大于计划剩余需求总量");
        }
        // æŒ‰è®¡åˆ’顺序分摊下发数量,收集实际参与下发的计划 ID
        BigDecimal remainingForOrderBind = assignedQuantity;
        List<Long> issuedPlanIds = new ArrayList<>();
        for (ProductionPlanDto plan : planLists) {
@@ -124,21 +142,20 @@
            }
        }
        if (issuedPlanIds.isEmpty()) {
            throw new ServiceException("Issue failed, no quantity available for dispatch");
            throw new ServiceException("下发失败,无可下发数量");
        }
        // ç”Ÿæˆç”Ÿäº§è®¢å•主单,并绑定本次下发关联的计划
        ProductionOrder productionOrder = new ProductionOrder();
        productionOrder.setProductionPlanIds(formatPlanIds(issuedPlanIds));
        productionOrder.setProductModelId(firstPlan.getProductModelId());
        productionOrder.setQuantity(assignedQuantity);
        productionOrder.setPlanCompleteTime(productionPlanDto.getPlanCompleteTime());
        boolean saved = productionOrderService.saveProductionOrder(productionOrder);
        if (!saved) {
        if (!productionOrderService.saveProductionOrder(productionOrder)) {
            throw new ServiceException("下发失败,生产订单保存失败");
        }
        //已下发数量
        // å›žå†™æ¯æ¡è®¡åˆ’的累计下发量和状态
        BigDecimal remainingAssignedQuantity = assignedQuantity;
        List<ProductionPlan> updates = new ArrayList<>();
        for (ProductionPlanDto plan : planLists) {
@@ -163,6 +180,7 @@
            updates.add(update);
        }
        if (!updates.isEmpty()) {
            // æ‰¹é‡æ›´æ–°è®¡åˆ’状态与数量
            this.updateBatchById(updates);
        }
        return true;
@@ -171,17 +189,24 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean add(ProductionPlanDto dto) {
        // æ–°å¢žä¸»ç”Ÿäº§è®¡åˆ’
        if (StringUtils.isBlank(dto.getMpsNo())) {
            dto.setMpsNo(generateNextPlanNo(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))));
        }else checkMpsNoUnique(dto.getMpsNo(), null);
            String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
            dto.setMpsNo(buildPlanNo(datePrefix, resolveNextPlanSequence(datePrefix)));
        } else {
            checkMpsNoUnique(dto.getMpsNo(), null);
        }
        dto.setStatus(PLAN_STATUS_WAIT);
        if (StringUtils.isBlank(dto.getSource())) {
        dto.setSource("内部");
        }
        return productionPlanMapper.insert(dto) > 0;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean update(ProductionPlanDto dto) {
        // æ›´æ–°ä¸»ç”Ÿäº§è®¡åˆ’
        if (dto == null || dto.getId() == null) {
            throw new ServiceException("编辑失败,数据不能为空");
        }
@@ -202,20 +227,10 @@
        return productionPlanMapper.updateById(dto) > 0;
    }
    private void checkMpsNoUnique(String mpsNo, Long excludeId) {
        LambdaQueryWrapper<ProductionPlan> wrapper = Wrappers.lambdaQuery();
        wrapper.eq(ProductionPlan::getMpsNo, mpsNo);
        if (excludeId != null) {
            wrapper.ne(ProductionPlan::getId, excludeId);
        }
        if (productionPlanMapper.selectCount(wrapper) > 0) {
            throw new ServiceException("生产计划号 " + mpsNo + " å·²å­˜åœ¨");
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean delete(List<Long> ids) {
        // åˆ é™¤ä¸»ç”Ÿäº§è®¡åˆ’
        if (productionPlanMapper.selectList(Wrappers.<ProductionPlan>lambdaQuery().in(ProductionPlan::getId, ids))
                .stream()
                .anyMatch(p -> p.getStatus() == PLAN_STATUS_PARTIAL || p.getStatus() == PLAN_STATUS_ISSUED)) {
@@ -234,6 +249,7 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void importProdData(MultipartFile file) {
        // å‚数与前置条件校验
        if (file == null || file.isEmpty()) {
            throw new ServiceException("导入数据不能为空");
        }
@@ -247,104 +263,251 @@
        if (list == null || list.isEmpty()) {
            throw new ServiceException("Excel没有数据");
        }
        String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        int nextSequence = resolveNextPlanSequence(datePrefix);
        Set<String> mpsNos = new HashSet<>();
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        for (int i = 0; i < list.size(); i++) {
            ProductionPlanImportDto dto = list.get(i);
            String mpsNo = dto.getMpsNo();
            String mpsNo = StringUtils.trim(dto.getMpsNo());
            if (StringUtils.isEmpty(mpsNo)) {
                generateNextPlanNo(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")));
                mpsNo = buildPlanNo(datePrefix, nextSequence++);
            }
            dto.setMpsNo(mpsNo);
            if (!mpsNos.add(mpsNo)) {
                throw new ServiceException("导入失败:Excel ä¸­å­˜åœ¨é‡å¤çš„申请单编号 " + mpsNo);
                throw new ServiceException("导入失败,Excel中存在重复的主生产计划号 " + mpsNo);
            }
            if (dto.getQtyRequired() == null || dto.getQtyRequired().compareTo(BigDecimal.ZERO) <= 0) {
                throw new ServiceException("导入失败:第" + (i + 2) + "行需求数量必须大于0");
            }
        }
        Long existApplyNoCount = baseMapper.selectCount(Wrappers.<ProductionPlan>lambdaQuery()
                .in(ProductionPlan::getMpsNo, mpsNos));
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        Long existApplyNoCount = baseMapper.selectCount(Wrappers.<ProductionPlan>lambdaQuery().in(ProductionPlan::getMpsNo, mpsNos));
        if (existApplyNoCount > 0) {
            List<String> existMpsNos = baseMapper.selectList(Wrappers.<ProductionPlan>lambdaQuery()
                            .in(ProductionPlan::getMpsNo, mpsNos))
            List<String> existMpsNos = baseMapper.selectList(Wrappers.<ProductionPlan>lambdaQuery().in(ProductionPlan::getMpsNo, mpsNos))
                    .stream()
                    .map(ProductionPlan::getMpsNo)
                    .collect(Collectors.toList());
            throw new ServiceException("导入失败,生产计划号已存在: " + String.join(", ", existMpsNos));
            throw new ServiceException("导入失败,主生产计划号已存在: " + String.join(", ", existMpsNos));
        }
        List<ProductModel> allModels = productModelMapper.selectList(Wrappers.<ProductModel>lambdaQuery());
        Set<Long> productIds = allModels.stream()
                .map(ProductModel::getProductId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, String> productNameById = productIds.isEmpty()
                ? Collections.emptyMap()
                : productMapper.selectBatchIds(productIds).stream()
                .collect(Collectors.toMap(Product::getId, Product::getProductName, (a, b) -> a));
        LocalDateTime now = LocalDateTime.now();
        List<ProductionPlan> entityList = list.stream().map(dto -> {
        List<ProductionPlan> entityList = new ArrayList<>();
        for (int i = 0; i < list.size(); i++) {
            ProductionPlanImportDto dto = list.get(i);
            ProductionPlan entity = new ProductionPlan();
            BeanUtils.copyProperties(dto, entity);
            entity.setProductModelId(resolveProductModelId(dto, i + 2, allModels, productNameById));
            entity.setStatus(PLAN_STATUS_WAIT);
            entity.setSource("内部");
            entity.setSource(StringUtils.isNotEmpty(dto.getSource()) ? StringUtils.trim(dto.getSource()) : "内部");
            entity.setQuantityIssued(BigDecimal.ZERO);
            entity.setCreateTime(now);
            entity.setUpdateTime(now);
            return entity;
        }).collect(Collectors.toList());
        this.saveBatch(entityList);
            entityList.add(entity);
        }
        // æŒä¹…化或输出处理结果
        if (!this.saveBatch(entityList)) {
            throw new ServiceException("导入失败,保存生产计划数据失败");
        }
    }
    @Override
    public void exportProdData(HttpServletResponse response, List<Long> ids) {
        List<ProductionPlanDto> list = productionPlanMapper.selectWithMaterialByIds(ids);
    public void exportProdData(HttpServletResponse response, ProductionPlanDto requestDto) {
        // å¯¼å‡ºä¸»ç”Ÿäº§è®¡åˆ’数据
        List<Long> ids = requestDto == null || requestDto.getIds() == null
                ? Collections.emptyList()
                : requestDto.getIds().stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
        List<ProductionPlanImportDto> exportList = new ArrayList<>();
        for (ProductionPlanDto entity : list) {
        if (!ids.isEmpty()) {
            List<ProductionPlanDto> list = productionPlanMapper.selectWithMaterialByIds(ids);
            for (ProductionPlanDto item : list) {
            ProductionPlanImportDto dto = new ProductionPlanImportDto();
            BeanUtils.copyProperties(entity, dto);
                BeanUtils.copyProperties(item, dto);
                dto.setAssignedQuantity(item.getQuantityIssued());
            exportList.add(dto);
        }
        } else {
            ProductionPlanDto query = new ProductionPlanDto();
            if (requestDto != null) {
                BeanUtils.copyProperties(requestDto, query);
            }
            IPage<ProductionPlanVo> page = productionPlanMapper.listPage(new Page<>(1, -1), query);
            if (page != null && page.getRecords() != null) {
                for (ProductionPlanVo item : page.getRecords()) {
                    ProductionPlanImportDto dto = new ProductionPlanImportDto();
                    BeanUtils.copyProperties(item, dto);
                    dto.setAssignedQuantity(item.getQuantityIssued());
                    exportList.add(dto);
                }
            }
        }
        ExcelUtil<ProductionPlanImportDto> util = new ExcelUtil<>(ProductionPlanImportDto.class);
        util.exportExcel(response, exportList, "主生产计划");
    }
    private BigDecimal resolveRemainingQuantity(ProductionPlan plan) {
        if (plan == null) {
            return BigDecimal.ZERO;
    /**
     * æ ¡éªŒä¸»ç”Ÿäº§è®¡åˆ’号唯一性,可通过 excludeId æŽ’除当前记录。
     */
    private void checkMpsNoUnique(String mpsNo, Long excludeId) {
        // æŒ‰ä¸»ç”Ÿäº§è®¡åˆ’号查询重复记录
        LambdaQueryWrapper<ProductionPlan> wrapper = Wrappers.lambdaQuery();
        wrapper.eq(ProductionPlan::getMpsNo, mpsNo);
        if (excludeId != null) {
            // æ›´æ–°æ—¶æŽ’除当前记录本身
            wrapper.ne(ProductionPlan::getId, excludeId);
        }
        BigDecimal requiredQuantity = Optional.ofNullable(plan.getQtyRequired()).orElse(BigDecimal.ZERO);
        if (requiredQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return BigDecimal.ZERO;
        if (productionPlanMapper.selectCount(wrapper) > 0) {
            // å­˜åœ¨é‡å¤è®¡åˆ’号,直接拦截
            throw new ServiceException("生产计划号 " + mpsNo + " å·²å­˜åœ¨");
        }
        BigDecimal issuedQuantity = Optional.ofNullable(plan.getQuantityIssued()).orElse(BigDecimal.ZERO);
        if (issuedQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return requiredQuantity;
        }
        if (issuedQuantity.compareTo(requiredQuantity) >= 0) {
            return BigDecimal.ZERO;
        }
        return requiredQuantity.subtract(issuedQuantity);
    }
    private int resolvePlanStatus(BigDecimal requiredQuantity, BigDecimal issuedQuantity) {
        if (requiredQuantity == null || requiredQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return PLAN_STATUS_WAIT;
        }
        if (issuedQuantity == null || issuedQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return PLAN_STATUS_WAIT;
        }
        return issuedQuantity.compareTo(requiredQuantity) < 0 ? PLAN_STATUS_PARTIAL : PLAN_STATUS_ISSUED;
    /**
     * æ ¹æ®å¯¼å…¥è¡Œçš„型号、产品名称、单位定位唯一的产品型号 ID。
     */
    private Long resolveProductModelId(ProductionPlanImportDto dto, int rowNo, List<ProductModel> allModels,
                                       Map<Long, String> productNameById) {
        // å…ˆæŒ‰è§„格型号做第一轮过滤
        String model = StringUtils.trim(dto.getModel());
        if (StringUtils.isEmpty(model)) {
            throw new ServiceException("导入失败:第" + rowNo + "行规格型号不能为空");
    }
    private String formatPlanIds(List<Long> planIds) {
        return planIds.stream()
                .filter(Objects::nonNull)
                .distinct()
                .map(String::valueOf)
                .collect(Collectors.joining(",", "[", "]"));
        List<ProductModel> candidates = allModels.stream()
                .filter(item -> model.equals(StringUtils.trim(item.getModel())))
                .collect(Collectors.toList());
        if (candidates.isEmpty()) {
            throw new ServiceException("导入失败:第" + rowNo + "行规格型号不存在,型号:" + model);
    }
    private String generateNextPlanNo(String datePrefix) {
        // è‹¥ä¼ äº†äº§å“åç§°ï¼Œå†åšç¬¬äºŒè½®è¿‡æ»¤
        String productName = StringUtils.trim(dto.getProductName());
        if (StringUtils.isNotEmpty(productName)) {
            candidates = candidates.stream()
                    .filter(item -> productName.equals(StringUtils.trim(productNameById.get(item.getProductId()))))
                    .collect(Collectors.toList());
            if (candidates.isEmpty()) {
                throw new ServiceException("导入失败:第" + rowNo + "行产品名称与规格型号不匹配");
            }
        }
        // è‹¥ä¼ äº†å•位,再做第三轮过滤
        String unit = StringUtils.trim(dto.getUnit());
        if (StringUtils.isNotEmpty(unit)) {
            candidates = candidates.stream()
                    .filter(item -> unit.equals(StringUtils.trim(item.getUnit())))
                    .collect(Collectors.toList());
            if (candidates.isEmpty()) {
                throw new ServiceException("导入失败:第" + rowNo + "行单位与规格型号不匹配");
            }
        }
        // ä»ç„¶å¤šæ¡è¯´æ˜Žä¿¡æ¯ä¸è¶³ä»¥å”¯ä¸€å®šä½
        if (candidates.size() > 1) {
            throw new ServiceException("导入失败:第" + rowNo + "行规格型号匹配到多个产品,请补充产品名称或单位");
        }
        return candidates.get(0).getId();
    }
    /**
     * ç”Ÿæˆä¸»ç”Ÿäº§è®¡åˆ’号,格式:JH + yyyyMMdd + 4位流水号。
     */
    private String buildPlanNo(String datePrefix, int sequence) {
        // ç»Ÿä¸€è®¡åˆ’号格式:JH + æ—¥æœŸ + 4位流水号
        return "JH" + datePrefix + String.format("%04d", sequence);
    }
    /**
     * æŸ¥è¯¢å½“日已存在的最大流水号,并返回下一个可用流水号。
     */
    private int resolveNextPlanSequence(String datePrefix) {
        // æŸ¥è¯¢å½“日最新一条计划号
        QueryWrapper<ProductionPlan> queryWrapper = new QueryWrapper<>();
        queryWrapper.likeRight("mps_no", "JH" + datePrefix);
        queryWrapper.orderByDesc("mps_no");
        queryWrapper.last("LIMIT 1");
        ProductionPlan latestPlan = productionPlanMapper.selectOne(queryWrapper);
        // é»˜è®¤ä»Ž 0001 å¼€å§‹
        int sequence = 1;
        if (latestPlan != null && latestPlan.getMpsNo() != null && !latestPlan.getMpsNo().isEmpty()) {
        if (latestPlan != null && StringUtils.isNotEmpty(latestPlan.getMpsNo())) {
            // æˆªå–末尾流水号并递增
            String sequenceStr = latestPlan.getMpsNo().substring(("JH" + datePrefix).length());
            try {
                sequence = Integer.parseInt(sequenceStr) + 1;
            } catch (NumberFormatException e) {
            } catch (NumberFormatException ignored) {
                // åŽ†å²æ•°æ®æ ¼å¼å¼‚å¸¸æ—¶å›žé€€åˆ° 0001
                sequence = 1;
            }
        }
        return "JH" + datePrefix + String.format("%04d", sequence);
        return sequence;
    }
    /**
     * è®¡ç®—生产计划的剩余未下发数量(需求量 - å·²ä¸‹å‘量,最小为 0)。
     */
    private BigDecimal resolveRemainingQuantity(ProductionPlan plan) {
        // ç©ºå¯¹è±¡æŒ‰ 0 å¤„理
        if (plan == null) {
            return BigDecimal.ZERO;
        }
        // éœ€æ±‚量为空或小于等于 0,视为无剩余
        BigDecimal requiredQuantity = Optional.ofNullable(plan.getQtyRequired()).orElse(BigDecimal.ZERO);
        if (requiredQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return BigDecimal.ZERO;
        }
        // å·²ä¸‹å‘量为空或小于等于 0,剩余即需求量
        BigDecimal issuedQuantity = Optional.ofNullable(plan.getQuantityIssued()).orElse(BigDecimal.ZERO);
        if (issuedQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return requiredQuantity;
        }
        // å·²ä¸‹å‘量大于等于需求量,剩余归零
        if (issuedQuantity.compareTo(requiredQuantity) >= 0) {
            return BigDecimal.ZERO;
        }
        // æ­£å¸¸åœºæ™¯è¿”回差值
        return requiredQuantity.subtract(issuedQuantity);
    }
    /**
     * æŒ‰éœ€æ±‚量与累计下发量推导计划状态。
     */
    private int resolvePlanStatus(BigDecimal requiredQuantity, BigDecimal issuedQuantity) {
        // æ— æœ‰æ•ˆéœ€æ±‚量时,状态保持待下发
        if (requiredQuantity == null || requiredQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return PLAN_STATUS_WAIT;
        }
        // æœ‰éœ€æ±‚但未下发,状态仍为待下发
        if (issuedQuantity == null || issuedQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return PLAN_STATUS_WAIT;
        }
        // å·²ä¸‹å‘量小于需求量为部分下发,否则为已下发
        return issuedQuantity.compareTo(requiredQuantity) < 0 ? PLAN_STATUS_PARTIAL : PLAN_STATUS_ISSUED;
    }
    /**
     * å°†è®¡åˆ’ ID é›†åˆè½¬æˆ [1,2,3] å½¢å¼ï¼Œå†™å…¥ç”Ÿäº§è®¢å•关联字段。
     */
    private String formatPlanIds(List<Long> planIds) {
        // åŽ»é‡å¹¶æ‹¼æŽ¥ä¸º [1,2,3] å½¢å¼çš„字符串
        return planIds.stream()
                .filter(Objects::nonNull)
                .distinct()
                .map(String::valueOf)
                .collect(Collectors.joining(",", "[", "]"));
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionProductInputServiceImpl.java
@@ -17,6 +17,7 @@
    @Override
    public IPage<ProductionProductInputDto> listPageProductionProductInputDto(Page page, ProductionProductInputDto productionProductInputDto) {
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§äº§å“å…¥åº“
        return productionProductInputMapper.listPageProductionProductInputDto(page, productionProductInputDto);
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionProductMainServiceImpl.java
@@ -82,6 +82,7 @@
    @Override
    public IPage<ProductionProductMainDto> listPageProductionProductMainDto(Page page, ProductionProductMainDto productionProductMainDto) {
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§æŠ¥å·¥ä¸»è¡¨
        IPage<ProductionProductMainDto> result = productionProductMainMapper.listPageProductionProductMainDto(page, productionProductMainDto);
        fillOperationParamList(result.getRecords());
        return result;
@@ -89,20 +90,24 @@
    @Override
    public IPage<ProductionProductMainDto> pageProductionProductMain(Page page, ProductionProductMainDto productionProductMainDto) {
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§æŠ¥å·¥ä¸»è¡¨
        return listPageProductionProductMainDto(page, productionProductMainDto);
    }
    @Override
    public ProductionProductMainDto getProductionProductMainInfo(Long id) {
        // èŽ·å–ç”Ÿäº§äº§å“ä¸»è¡¨è¯¦æƒ…
        return listPageProductionProductMainDto(new Page<>(1, 1), new ProductionProductMainDto() {{
            setId(id);
        }}).getRecords().stream().findFirst().orElse(null);
    }
    private void fillOperationParamList(List<ProductionProductMainDto> recordList) {
        // å¡«å……工序参数列表
        if (recordList == null || recordList.isEmpty()) {
            return;
        }
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
        Set<Long> mainIdSet = recordList.stream()
                .map(ProductionProductMainDto::getId)
                .filter(Objects::nonNull)
@@ -112,6 +117,7 @@
            return;
        }
        // æŸ¥è¯¢å¹¶å‡†å¤‡ä¸šåŠ¡æ•°æ®
        List<ProductionOrderRoutingOperationParam> paramList = productionOrderRoutingOperationParamMapper.selectList(
                Wrappers.<ProductionOrderRoutingOperationParam>lambdaQuery()
                        .in(ProductionOrderRoutingOperationParam::getProductionProductMainId, mainIdSet)
@@ -211,6 +217,7 @@
    @Override
    public Boolean addProductMain(ProductionProductMainDto dto) {
        // æ–°å¢žç”Ÿäº§æŠ¥å·¥ä¸»è®°å½•
        Long taskId = resolveTaskId(dto);
        if (taskId == null) {
            throw new ServiceException("请传入生产工单ID");
@@ -220,11 +227,13 @@
    @Override
    public Boolean saveProductionProductMain(ProductionProductMainDto productionProductMainDto) {
        // ä¿å­˜ç”Ÿäº§æŠ¥å·¥ä¸»è®°å½•
        return addProductMain(productionProductMainDto);
    }
    @Override
    public Boolean removeProductMain(Long id) {
        // åˆ é™¤ç”Ÿäº§æŠ¥å·¥ä¸»è®°å½•
        ProductionProductMain currentMain = productionProductMainMapper.selectById(id);
        if (currentMain == null) {
            return true;
@@ -233,10 +242,10 @@
    }
    private Boolean addProductMainByProductionTask(ProductionProductMainDto dto) {
        // æŠ¥å·¥ä»¥è®¢å•工序快照为准,避免工艺主数据变更后影响历史工单执行。
        // æŒ‰ç”Ÿäº§ä»»åŠ¡æ–°å¢žæŠ¥å·¥ä¸»è®°å½•
        Long taskId = resolveTaskId(dto);
        if (taskId == null) {
            throw new ServiceException("productionOperationTaskId can not be null");
            throw new ServiceException("生产工单ID不能为空");
        }
        SysUser user = userMapper.selectUserById(dto.getUserId());
        ProductionOperationTask productionOperationTask = productionOperationTaskMapper.selectById(taskId);
@@ -266,6 +275,7 @@
        productionProductMain.setUserName(user == null ? dto.getUserName() : user.getNickName());
        productionProductMain.setProductionOperationTaskId(taskId);
        productionProductMain.setStatus(0);
        productionProductMain.setWorkHour(dto.getWorkHour());
        productionProductMainMapper.insert(productionProductMain);
        syncOperationParamInputValue(dto, routingOperation.getId(), productionProductMain.getId());
@@ -522,12 +532,14 @@
    }
    private Boolean removeProductMainByProductionTask(ProductionProductMain productionProductMain) {
        // åˆ é™¤æŠ¥å·¥éœ€è¦åŒæ­¥å›žæ»šè´¨æ£€ã€åº“存、工时核算和订单/工单进度。
        // æŒ‰ç”Ÿäº§ä»»åŠ¡å›žæ»šå¹¶åˆ é™¤æŠ¥å·¥ä¸»è®°å½•
        List<QualityInspect> qualityInspects = qualityInspectMapper.selectList(
                Wrappers.<QualityInspect>lambdaQuery().eq(QualityInspect::getProductMainId, productionProductMain.getId()));
        // å‚数与前置条件校验
        if (qualityInspects.size() > 0) {
            List<QualityUnqualified> qualityUnqualifieds = qualityUnqualifiedMapper.selectList(
                    Wrappers.<QualityUnqualified>lambdaQuery()
        // éåŽ†å¤„ç†æ•°æ®å¹¶ç»„è£…ç»“æžœ
                            .in(QualityUnqualified::getInspectId, qualityInspects.stream().map(QualityInspect::getId).collect(Collectors.toList())));
            if (qualityUnqualifieds.size() > 0 && qualityUnqualifieds.get(0).getInspectState() == 1) {
                throw new ServiceException("该条报工已经不合格处理了,不允许删除");
@@ -552,6 +564,7 @@
            } else {
                productionOperationTask.setStatus(3);
            }
        // æŒä¹…化或输出处理结果
            productionOperationTaskMapper.updateById(productionOperationTask);
            ProductionOrder productionOrder = productionOrderMapper.selectById(productionOperationTask.getProductionOrderId());
@@ -600,6 +613,7 @@
    }
    private String generateProductNo() {
        // ç”Ÿæˆä¸‹ä¸€ä¸ªç”Ÿäº§äº§å“ç¼–号
        String datePrefix = "BG" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd"));
        QueryWrapper<ProductionProductMain> queryWrapper = new QueryWrapper<>();
        queryWrapper.select("MAX(product_no) as maxNo").likeRight("product_no", datePrefix);
@@ -622,10 +636,12 @@
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        // å°†ç©ºæ•°é‡å…œåº•为0,避免空指针异常
        return value == null ? BigDecimal.ZERO : value;
    }
    private Long resolveTaskId(ProductionProductMainDto dto) {
        // ä»Žå…¥å‚中解析生产工单ID并校验
        if (dto == null) {
            return null;
        }
@@ -634,6 +650,7 @@
    @Override
    public ArrayList<Long> listMain(List<Long> idList) {
        // æŸ¥è¯¢ä¸»è¡¨ID集合
        return productionProductMainMapper.listMain(idList);
    }
}
src/main/java/com/ruoyi/production/service/impl/ProductionProductOutputServiceImpl.java
@@ -17,6 +17,7 @@
    @Override
    public IPage<ProductionProductOutputDto> listPageProductionProductOutputDto(Page page, ProductionProductOutputDto productionProductOutputDto) {
        // åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§äº§å“å‡ºåº“
        return productionProductOutputMapper.listPageProductionProductOutputDto(page, productionProductOutputDto);
    }
}
src/main/java/com/ruoyi/production/service/impl/SalesLedgerProductionAccountingServiceImpl.java
@@ -15,6 +15,7 @@
    @Override
    public UserAccountDto getByUserId(UserProductionAccountingDto dto) {
        // æŒ‰ç”¨æˆ·æŸ¥è¯¢ç”Ÿäº§æ ¸ç®—信息
        if (dto == null || dto.getUserId() == null || dto.getDate() == null || dto.getDate().trim().isEmpty()) {
            return new UserAccountDto();
        }
src/main/java/com/ruoyi/purchase/dto/PurchaseLedgerDto.java
@@ -48,11 +48,11 @@
     */
    @Excel(name = "供应商名称")
    private String supplierName;
     /**
     * æ˜¯å¦ç™½åå•
     */
    @Excel(name = "是否白名单")
    private Integer isWhite;
//     /**
//     * æ˜¯å¦ç™½åå•
//     */
//    @Excel(name = "是否白名单")
//    private Integer isWhite;
    /**
     * å½•入人姓名id
src/main/java/com/ruoyi/purchase/dto/PurchaseReturnOrderHasAllInfoDto.java
ÎļþÃû´Ó src/main/java/com/ruoyi/purchase/vo/PurchaseReturnOrderVo.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.purchase.vo;
package com.ruoyi.purchase.dto;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
import lombok.AllArgsConstructor;
@@ -8,7 +8,7 @@
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PurchaseReturnOrderVo extends PurchaseReturnOrders {
public class PurchaseReturnOrderHasAllInfoDto extends PurchaseReturnOrders {
    //供应商名称
    private String supplierName;
src/main/java/com/ruoyi/purchase/mapper/PurchaseReturnOrdersMapper.java
@@ -5,7 +5,8 @@
import com.ruoyi.purchase.dto.PurchaseReturnOrderDto;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.purchase.vo.PurchaseReturnOrderVo;
import com.ruoyi.purchase.dto.PurchaseReturnOrderHasAllInfoDto;
import jakarta.validation.constraints.NotNull;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -19,5 +20,7 @@
 */
@Mapper
public interface PurchaseReturnOrdersMapper extends BaseMapper<PurchaseReturnOrders> {
    IPage<PurchaseReturnOrderVo> listPage(Page page, @Param("params") PurchaseReturnOrderDto purchaseReturnOrder);
    IPage<PurchaseReturnOrderHasAllInfoDto> listPage(Page page, @Param("params") PurchaseReturnOrderDto purchaseReturnOrder);
    PurchaseReturnOrderHasAllInfoDto getPurchaseReturnOrderHasAllInfoById(@Param("id") @NotNull Long id);
}
src/main/java/com/ruoyi/purchase/service/PurchaseReturnOrdersService.java
@@ -6,7 +6,7 @@
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.purchase.vo.PurchaseReturnDetailsVo;
import com.ruoyi.purchase.vo.PurchaseReturnOrderVo;
import com.ruoyi.purchase.dto.PurchaseReturnOrderHasAllInfoDto;
import jakarta.validation.constraints.NotNull;
@@ -19,7 +19,7 @@
 * @since 2026-03-06 11:44:38
 */
public interface PurchaseReturnOrdersService extends IService<PurchaseReturnOrders> {
    IPage<PurchaseReturnOrderVo> listPage(Page page, PurchaseReturnOrderDto purchaseReturnOrderDto);
    IPage<PurchaseReturnOrderHasAllInfoDto> listPage(Page page, PurchaseReturnOrderDto purchaseReturnOrderDto);
    Boolean add(PurchaseReturnOrderDto purchaseReturnOrderDto);
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java
@@ -132,6 +132,8 @@
    @Transactional(rollbackFor = Exception.class)
    public int addOrEditPurchase(PurchaseLedgerDto purchaseLedgerDto) throws Exception {
        PurchaseLedger purchaseLedger = new PurchaseLedger();
        // DTO转Entity
        BeanUtils.copyProperties(purchaseLedgerDto, purchaseLedger);
        SalesLedger salesLedger = salesLedgerMapper.selectById(purchaseLedgerDto.getSalesLedgerId());
        //录入人
        SysUser sysUser = userMapper.selectUserById(purchaseLedgerDto.getRecorderId());
@@ -146,9 +148,6 @@
        SupplierManage supplierManage = supplierManageMapper.selectById(purchaseLedgerDto.getSupplierId());
        // DTO转Entity
        BeanUtils.copyProperties(purchaseLedgerDto, purchaseLedger);
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (ObjectUtils.isNotEmpty(loginUser) && null != loginUser.getTenantId()) {
            purchaseLedger.setTenantId(loginUser.getTenantId());
@@ -174,23 +173,14 @@
            }
            purchaseLedgerMapper.updateById(purchaseLedger);
        }
        // 6.采购审核新增;审批管理未配置采购审批人时,审批服务会自动置为审批通过。
        addApproveByPurchase(loginUser, purchaseLedger);
        // 4. å¤„理子表数据
        List<SalesLedgerProduct> productList = purchaseLedgerDto.getProductData();
        if (productList != null && !productList.isEmpty()) {
            handleSalesLedgerProducts(purchaseLedger.getId(), productList, purchaseLedgerDto.getType());
        }
        //新增原材料检验  å®¡æ‰¹ä¹‹åŽæ‰ç”Ÿæˆæ£€éªŒ
//        if (productList != null) {
//            for (SalesLedgerProduct saleProduct : productList) {
//                //是否推送质检,如果true就添加
//                if (saleProduct.getIsChecked()) {
//                    addQualityInspect(purchaseLedger, saleProduct);
//                }
//            }
//        }
        // 6.采购审核新增;审批管理未配置采购审批人时,审批服务会自动置为审批通过。
        addApproveByPurchase(loginUser, purchaseLedger);
        // 5. è¿ç§»ä¸´æ—¶æ–‡ä»¶åˆ°æ­£å¼ç›®å½•
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.PURCHASE_LEDGER, purchaseLedger.getId(), purchaseLedgerDto.getStorageBlobDTOS());
        return 1;
src/main/java/com/ruoyi/purchase/service/impl/PurchaseReturnOrdersServiceImpl.java
@@ -4,25 +4,33 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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.account.pojo.AccountIncome;
import com.ruoyi.account.service.AccountIncomeService;
import com.ruoyi.common.enums.SaleEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.purchase.dto.PurchaseReturnOrderDto;
import com.ruoyi.purchase.dto.PurchaseReturnOrderProductsDto;
import com.ruoyi.purchase.mapper.PurchaseLedgerMapper;
import com.ruoyi.purchase.mapper.PurchaseReturnOrderProductsMapper;
import com.ruoyi.purchase.mapper.PurchaseReturnOrdersMapper;
import com.ruoyi.purchase.pojo.PurchaseLedger;
import com.ruoyi.purchase.pojo.PurchaseReturnOrderProducts;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
import com.ruoyi.purchase.service.PurchaseReturnOrdersService;
import com.ruoyi.purchase.vo.PurchaseReturnDetailsVo;
import com.ruoyi.purchase.vo.PurchaseReturnOrderVo;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.purchase.dto.PurchaseReturnOrderHasAllInfoDto;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.service.ISalesLedgerService;
import com.ruoyi.stock.mapper.StockOutRecordMapper;
import com.ruoyi.stock.pojo.StockOutRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -48,9 +56,13 @@
    private final PurchaseReturnOrderProductsMapper purchaseReturnOrderProductsMapper;
    private final ISalesLedgerService salesLedgerService;
    private final AccountIncomeService accountIncomeService;
    private final StockUtils stockUtils;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final PurchaseLedgerMapper purchaseLedgerMapper;
    private final StockOutRecordMapper stockOutRecordMapper;
    @Override
    public IPage<PurchaseReturnOrderVo> listPage(Page page, PurchaseReturnOrderDto purchaseReturnOrderDto) {
    public IPage<PurchaseReturnOrderHasAllInfoDto> listPage(Page page, PurchaseReturnOrderDto purchaseReturnOrderDto) {
        return purchaseReturnOrdersMapper.listPage(page, purchaseReturnOrderDto);
    }
@@ -67,6 +79,10 @@
                // è¿™é‡Œä¸ºæ–°å¢žå› æ­¤id为null
                purchaseReturnOrderProductsDto.setId(null);
                purchaseReturnOrderProductsMapper.insert(purchaseReturnOrderProductsDto);
                //库存需要出库(采购退货)
                PurchaseLedger purchaseLedger = purchaseLedgerMapper.selectById(purchaseReturnOrderDto.getPurchaseLedgerId());
                SalesLedgerProduct salesLedgerProduct = salesLedgerProductMapper.selectById(purchaseReturnOrderProductsDto.getSalesLedgerProductId());
                stockUtils.substractStock(salesLedgerProduct.getProductModelId(), purchaseReturnOrderProductsDto.getReturnQuantity(), StockOutQualifiedRecordTypeEnum.PURCHASE_RETURN_STOCK_OUT.getCode(), purchaseReturnOrderDto.getId(), purchaseLedger.getPurchaseContractNumber()+"-"+salesLedgerProduct.getId());
            }
        }else {
            throw new RuntimeException("请选择退货商品");
@@ -91,7 +107,7 @@
    @Override
    public PurchaseReturnDetailsVo getPurchaseReturnOrderDtoById(Long id) {
        PurchaseReturnOrders purchaseReturnOrders = purchaseReturnOrdersMapper.selectById(id);
        PurchaseReturnOrderHasAllInfoDto purchaseReturnOrders = purchaseReturnOrdersMapper.getPurchaseReturnOrderHasAllInfoById(id);
        PurchaseReturnDetailsVo purchaseReturnOrderDto = BeanUtil.copyProperties(purchaseReturnOrders, PurchaseReturnDetailsVo.class);
        // æŸ¥è¯¢å‡ºä»–具体对应的退货
        LambdaQueryWrapper<PurchaseReturnOrderProducts> queryWrapper = new LambdaQueryWrapper<>();
@@ -120,7 +136,10 @@
        LambdaUpdateWrapper<PurchaseReturnOrderProducts> updateWrapper = new LambdaUpdateWrapper<>();
        updateWrapper.eq(PurchaseReturnOrderProducts::getPurchaseReturnOrderId, id);
        purchaseReturnOrderProductsMapper.delete(updateWrapper);
        //(采购退货的数据需要删掉)
        stockOutRecordMapper.delete(Wrappers.<StockOutRecord>lambdaQuery()
                .eq(StockOutRecord::getRecordType,StockOutQualifiedRecordTypeEnum.PURCHASE_RETURN_STOCK_OUT.getCode())
                .eq(StockOutRecord::getRecordId, id));
        // è´¢åŠ¡
        LambdaUpdateWrapper<AccountIncome> updateWrapperAccountIncome = new LambdaUpdateWrapper<>();
        updateWrapperAccountIncome.eq(AccountIncome::getBusinessId, id);
src/main/java/com/ruoyi/purchase/vo/PurchaseReturnDetailsVo.java
@@ -1,6 +1,6 @@
package com.ruoyi.purchase.vo;
import com.ruoyi.purchase.pojo.PurchaseReturnOrders;
import com.ruoyi.purchase.dto.PurchaseReturnOrderHasAllInfoDto;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import lombok.AllArgsConstructor;
import lombok.Data;
@@ -20,7 +20,7 @@
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PurchaseReturnDetailsVo extends PurchaseReturnOrders implements Serializable {
public class PurchaseReturnDetailsVo extends PurchaseReturnOrderHasAllInfoDto implements Serializable {
    private List<PurchaseReturnOrderProductsDetailVo> purchaseReturnOrderProductsDetailVoList;
src/main/java/com/ruoyi/sales/controller/SalesLedgerProductController.java
@@ -71,7 +71,6 @@
        if (CollUtil.isEmpty(list)) {
            return AjaxResult.success(list);
        }
        //
        List<Long> productIds = list.stream().map(SalesLedgerProduct::getId).collect(Collectors.toList());
        List<SimpleReturnOrderGroupDto> groupListByProductIds = purchaseReturnOrderProductsMapper.getReturnOrderGroupListByProductIds(productIds);
        Map<Long, BigDecimal> returnOrderGroupDtoMap = groupListByProductIds.stream().collect(Collectors.toMap(SimpleReturnOrderGroupDto::getSalesLedgerProductId, item -> item.getSumReturnQuantity()));
@@ -83,13 +82,6 @@
            if (item.getFutureTicketsAmount().compareTo(BigDecimal.ZERO) == 0) {
                item.setFutureTicketsAmount(BigDecimal.ZERO);
            }
//            ProcurementPageDto procurementDto = new ProcurementPageDto();
//            procurementDto.setSalesLedgerProductId(item.getId());
//            procurementDto.setProductCategory(item.getProductCategory());
//            IPage<ProcurementPageDtoCopy> result = procurementRecordService.listPageCopyByProduction(new Page<>(1,-1), procurementDto);
//            BigDecimal stockQuantity = stockUtils.getStockQuantity(item.getProductModelId()).get("stockQuantity");
//                ProcurementPageDtoCopy procurementDtoCopy = result.getRecords().get(0);
            if (item.getApproveStatus() != 2) {
                if (item.getHasSufficientStock() == 0) {
                    item.setApproveStatus(0);
src/main/java/com/ruoyi/sales/controller/ShipmentApprovalController.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/sales/controller/ShippingInfoController.java
@@ -61,7 +61,7 @@
        ApproveProcessVO approveProcessVO = new ApproveProcessVO();
        approveProcessVO.setApproveType(7);
        approveProcessVO.setApproveDeptId(loginUser.getCurrentDeptId());
        approveProcessVO.setApproveReason(req.getType() + ":" +sh);
        approveProcessVO.setApproveReason(sh);//发货编号
        approveProcessVO.setApproveUserIds(req.getApproveUserIds());
        approveProcessVO.setApproveUser(loginUser.getUserId());
        approveProcessVO.setApproveTime(LocalDate.now().toString());
@@ -122,7 +122,14 @@
    }
    @GetMapping("/getDateil/{id}")
    @Operation(summary = "通过id查询详情")
    public R getDateil(@PathVariable("id") Long id) {
        return R.ok(shippingInfoService.getDetail(id));
    }
    @GetMapping("/getDateilByShippingNo")
    @Operation(summary = "通过发货单号查询详情")
    public R getDateilByShippingNo(String shippingNo) {
        return R.ok(shippingInfoService.getDateilByShippingNo(shippingNo));
    }
}
src/main/java/com/ruoyi/sales/dto/ShippingApproveDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package com.ruoyi.sales.dto;
import com.ruoyi.sales.pojo.ShippingInfo;
import lombok.Data;
import java.util.List;
//发货审批查看详情
@Data
public class ShippingApproveDto {
    private ShippingInfo shippingInfo;
    private List<ShippingProductDetailDto> shippingProductDetailDtoList;
}
src/main/java/com/ruoyi/sales/mapper/ShipmentApprovalMapper.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/sales/mapper/ShippingInfoMapper.java
@@ -3,10 +3,11 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.bean.dto.SalesOutboundDto;
import com.ruoyi.account.bean.vo.SalesOutboundVo;
import com.ruoyi.sales.dto.SalesLedgerProductDto;
import com.ruoyi.sales.dto.ShippingInfoDto;
import com.ruoyi.sales.pojo.ShippingInfo;
import com.ruoyi.sales.pojo.ShippingProductDetail;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -24,5 +25,5 @@
    List<ShippingInfo> getShippingInfoByCustomerName(String customerName);
    List<ShippingProductDetail> getDateil(Long id);
    IPage<SalesOutboundVo> listPageByOutbound(Page page, @Param("req") SalesOutboundDto salesOutboundDto);
}
src/main/java/com/ruoyi/sales/mapper/ShippingProductDetailMapper.java
@@ -4,6 +4,7 @@
import com.ruoyi.sales.dto.ShippingProductDetailDto;
import com.ruoyi.sales.pojo.ShippingProductDetail;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -19,4 +20,6 @@
public interface ShippingProductDetailMapper extends BaseMapper<ShippingProductDetail> {
    List<ShippingProductDetailDto> getDetail(Long id);
    List<ShippingProductDetailDto> getDateilByShippingNo(@Param("shippingNo") String shippingNo);
}
src/main/java/com/ruoyi/sales/pojo/SalesLedgerProduct.java
@@ -257,5 +257,6 @@
    private Boolean isProduction;
    @TableField(exist = false)
    @Schema(description = "待发货数量")
    private BigDecimal noQuantity;
}
src/main/java/com/ruoyi/sales/pojo/ShipmentApproval.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/sales/service/ShipmentApprovalService.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/sales/service/ShippingInfoService.java
@@ -4,6 +4,7 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.sales.dto.SalesLedgerProductDto;
import com.ruoyi.sales.dto.ShippingApproveDto;
import com.ruoyi.sales.dto.ShippingInfoDto;
import com.ruoyi.sales.dto.ShippingProductDetailDto;
import com.ruoyi.sales.pojo.ShippingInfo;
@@ -28,4 +29,6 @@
    boolean add(ShippingInfoDto req);
    List<ShippingProductDetailDto> getDetail(Long id);
    ShippingApproveDto getDateilByShippingNo(String shippingNo);
}
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerProductServiceImpl.java
@@ -101,9 +101,6 @@
    @Override
    public List<SalesLedgerProduct> selectSalesLedgerProductList(SalesLedgerProduct salesLedgerProduct) {
//        LambdaQueryWrapper<SalesLedgerProduct> queryWrapper = new LambdaQueryWrapper<>();
//        queryWrapper.eq(SalesLedgerProduct::getSalesLedgerId, salesLedgerProduct.getSalesLedgerId())
//                .eq(SalesLedgerProduct::getType, salesLedgerProduct.getType());
        List<SalesLedgerProduct> salesLedgerProducts = salesLedgerProductMapper.selectSalesLedgerProductList(salesLedgerProduct);
        if(!CollectionUtils.isEmpty(salesLedgerProducts)){
            salesLedgerProducts.forEach(item -> {
src/main/java/com/ruoyi/sales/service/impl/SalesQuotationServiceImpl.java
@@ -61,6 +61,8 @@
    public boolean add(SalesQuotationDto salesQuotationDto) {
        LoginUser loginUser = SecurityUtils.getLoginUser();
        SalesQuotation salesQuotation = new SalesQuotation();
        BeanUtils.copyProperties(salesQuotationDto, salesQuotation);
        salesQuotation.setId(null);
        Customer customer = customerMapper.selectById(Long.valueOf(salesQuotationDto.getCustomerId()));
        if (ObjectUtils.isNotEmpty(customer))  {
            salesQuotation.setCustomer(customer.getCustomerName());
src/main/java/com/ruoyi/sales/service/impl/ShipmentApprovalServiceImpl.java
ÎļþÒÑɾ³ý
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java
@@ -13,6 +13,7 @@
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.procurementrecord.utils.StockUtils;
import com.ruoyi.sales.dto.SalesLedgerProductDto;
import com.ruoyi.sales.dto.ShippingApproveDto;
import com.ruoyi.sales.dto.ShippingInfoDto;
import com.ruoyi.sales.dto.ShippingProductDetailDto;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
@@ -68,7 +69,6 @@
        }
        //扣减库存
        if(!"已发货".equals(byId.getStatus())){
//            SalesLedgerProduct salesLedgerProduct = salesLedgerProductMapper.selectById(byId.getSalesLedgerProductId());
            List<ShippingProductDetail> shippingProductDetails = shippingProductDetailMapper.selectList(new LambdaQueryWrapper<ShippingProductDetail>().eq(ShippingProductDetail::getShippingInfoId, req.getId()));
            if (CollectionUtils.isEmpty(shippingProductDetails)) {
                throw new RuntimeException("发货信息不存在");
@@ -141,4 +141,15 @@
    public List<ShippingProductDetailDto> getDetail(Long id) {
        return shippingProductDetailMapper.getDetail(id);
    }
    @Override
    public ShippingApproveDto getDateilByShippingNo(String shippingNo) {
        ShippingApproveDto shippingApproveDto = new ShippingApproveDto();
        ShippingInfo shippingInfo = new ShippingInfo();
        shippingInfo.setShippingNo(shippingNo);
        shippingApproveDto.setShippingInfo(shippingInfoMapper.listPage(new Page(1, -1),shippingInfo).getRecords().get(0));
        List<ShippingProductDetailDto> dateilByShippingNo = shippingProductDetailMapper.getDateilByShippingNo(shippingNo);
        shippingApproveDto.setShippingProductDetailDtoList(dateilByShippingNo);
        return shippingApproveDto;
    }
}
src/main/java/com/ruoyi/staff/controller/StaffOnJobController.java
@@ -6,19 +6,18 @@
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.staff.dto.StaffOnJobDto;
import com.ruoyi.staff.dto.StaffOnJobExcelDto;
import com.ruoyi.staff.pojo.StaffContract;
import com.ruoyi.staff.pojo.StaffOnJob;
import com.ruoyi.staff.service.IStaffOnJobService;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.staff.dto.StaffOnJobExcelDto;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import java.util.List;
/**
@@ -103,7 +102,7 @@
     * @return
     */
    @PostMapping("/renewContract/{id}")
    public AjaxResult renewContract(@PathVariable("id") Long id, @RequestBody StaffContract staffContract) {
    public AjaxResult renewContract(@PathVariable Long id, @RequestBody StaffContract staffContract) {
        return AjaxResult.success(staffOnJobService.renewContract(id, staffContract));
    }
src/main/java/com/ruoyi/technology/pojo/TechnologyOperation.java
@@ -49,7 +49,7 @@
    @Schema(description = "是否质检")
    private Boolean isQuality;
    @Schema(description = "类型 åŒºåˆ†è®¡æ—¶å’Œè®¡ä»¶")
    @Schema(description = "类型 åŒºåˆ†è®¡æ—¶å’Œè®¡ä»¶ï¼Œ0计时,1计件")
    private Integer type;
    @Schema(description = "设备id")
src/main/java/com/ruoyi/technology/pojo/TechnologyRoutingOperation.java
@@ -58,4 +58,7 @@
    @Schema(description = "部门ID")
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    @Schema(description = "类型 åŒºåˆ†è®¡æ—¶å’Œè®¡ä»¶ï¼Œ0计时,1计件")
    private Integer type;
}
src/main/java/com/ruoyi/technology/service/impl/TechnologyBomServiceImpl.java
@@ -108,12 +108,12 @@
    @Transactional(rollbackFor = Exception.class)
    public R update(TechnologyBom technologyBom) {
        if (technologyBom.getId() == null) {
            throw new ServiceException("BOM id is required");
            throw new ServiceException("BOM ID不能为空");
        }
        validateProductModel(technologyBom.getProductModelId());
        TechnologyBom oldBom = technologyBomMapper.selectById(technologyBom.getId());
        if (oldBom == null) {
            throw new ServiceException("BOM not found");
            throw new ServiceException("BOM不存在");
        }
        if (oldBom.getProductModelId() != null && !oldBom.getProductModelId().equals(technologyBom.getProductModelId())) {
            technologyRoutingMapper.updateProductModelByBomId(technologyBom.getProductModelId(), technologyBom.getId().longValue());
@@ -135,12 +135,12 @@
    @Transactional(rollbackFor = Exception.class)
    public boolean batchDelete(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("Select at least one BOM");
            throw new ServiceException("请至少选择一个BOM");
        }
        List<TechnologyRouting> list = technologyRoutingMapper.selectList(Wrappers.<TechnologyRouting>lambdaQuery()
                .in(TechnologyRouting::getBomId, ids));
        if (!list.isEmpty()) {
            throw new ServiceException("BOM is referenced by routing");
            throw new ServiceException("BOM已被工艺路线引用,不能删除");
        }
        technologyBomStructureService.remove(Wrappers.<TechnologyBomStructure>lambdaQuery()
                .in(TechnologyBomStructure::getBomId, ids));
@@ -152,11 +152,11 @@
     */
    private void validateProductModel(Long productModelId) {
        if (productModelId == null) {
            throw new ServiceException("Product model is required");
            throw new ServiceException("产品规格ID不能为空");
        }
        ProductModel productModel = productModelService.getById(productModelId);
        if (productModel == null) {
            throw new ServiceException("Product model not found");
            throw new ServiceException("产品规格不存在");
        }
    }
src/main/java/com/ruoyi/technology/service/impl/TechnologyOperationParamServiceImpl.java
@@ -41,21 +41,21 @@
    public boolean saveTechnologyOperationParam(TechnologyOperationParam technologyOperationParam) {
        if (technologyOperationParam.getTechnologyOperationId() == null
                || technologyOperationMapper.selectById(technologyOperationParam.getTechnologyOperationId()) == null) {
            throw new ServiceException("Operation not found");
            throw new ServiceException("工序不存在");
        }
        if (technologyOperationParam.getTechnologyParamId() == null) {
            throw new ServiceException("Param is required");
            throw new ServiceException("参数ID不能为空");
        }
        TechnologyParam technologyParam = technologyParamMapper.selectById(technologyOperationParam.getTechnologyParamId());
        if (technologyParam == null) {
            throw new ServiceException("Param not found");
            throw new ServiceException("参数不存在");
        }
        boolean duplicate = technologyOperationParamMapper.selectCount(Wrappers.<TechnologyOperationParam>lambdaQuery()
                .eq(TechnologyOperationParam::getTechnologyOperationId, technologyOperationParam.getTechnologyOperationId())
                .eq(TechnologyOperationParam::getTechnologyParamId, technologyOperationParam.getTechnologyParamId())
                .ne(technologyOperationParam.getId() != null, TechnologyOperationParam::getId, technologyOperationParam.getId())) > 0;
        if (duplicate) {
            throw new ServiceException("Duplicate param in operation");
            throw new ServiceException("工序参数重复");
        }
        return this.saveOrUpdate(technologyOperationParam);
    }
src/main/java/com/ruoyi/technology/service/impl/TechnologyParamServiceImpl.java
@@ -2,18 +2,22 @@
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.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.technology.bean.dto.TechnologyParamDto;
import com.ruoyi.technology.bean.vo.TechnologyParamVo;
import com.ruoyi.technology.mapper.TechnologyOperationParamMapper;
import com.ruoyi.technology.mapper.TechnologyParamMapper;
import com.ruoyi.technology.pojo.TechnologyOperationParam;
import com.ruoyi.technology.pojo.TechnologyParam;
import com.ruoyi.technology.service.TechnologyParamService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -28,6 +32,7 @@
    private static final List<Integer> VALID_PARAM_TYPES = Arrays.asList(1, 2, 3, 4);
    private static final String PARAM_CODE_PREFIX = "PARAM_";
    private static final Byte DATE_PARAM_TYPE = (byte) 4;
    private final TechnologyOperationParamMapper technologyOperationParamMapper;
    /**
     * åˆ†é¡µæŸ¥è¯¢åŸºç¡€å‚数并格式化日期类型展示。
@@ -161,10 +166,13 @@
     * æ‰¹é‡åˆ é™¤åŸºç¡€å‚数。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteBaseParamByIds(Long[] ids) {
        if (ids == null || ids.length == 0) {
            throw new RuntimeException("删除ID不能为空");
        }
        return baseMapper.deleteBatchIds(Arrays.asList(ids));
        technologyOperationParamMapper.delete(Wrappers.<TechnologyOperationParam>lambdaQuery()
                .in(TechnologyOperationParam::getTechnologyParamId, Arrays.asList(ids)));
        return baseMapper.deleteByIds(Arrays.asList(ids));
    }
}
src/main/java/com/ruoyi/technology/service/impl/TechnologyRoutingServiceImpl.java
@@ -143,7 +143,10 @@
            routingOperation.setProductModelId(resolveOutputProductModelId(bomStructure, structureById, technologyRouting.getProductModelId()));
            routingOperation.setTechnologyOperationId(bomStructure.getOperationId());
            routingOperation.setDragSort(dragSort++);
            routingOperation.setIsQuality(getOperationQuality(bomStructure.getOperationId()));
            TechnologyOperation technologyOperation = getOperation(bomStructure.getOperationId());
            routingOperation.setIsQuality(technologyOperation != null ? technologyOperation.getIsQuality() : null);
            routingOperation.setIsProduction(technologyOperation != null ? technologyOperation.getIsProduction() : null);
            routingOperation.setType(technologyOperation != null ? technologyOperation.getType() : null);
            technologyRoutingOperationMapper.insert(routingOperation);
            syncRoutingOperationParams(routingOperation.getId(), bomStructure.getOperationId());
        }
@@ -204,12 +207,11 @@
        }
    }
    /**
     * è´¨æ£€æ ‡è¯†ä»¥å·¥åºåŸºç¡€è¡¨å®šä¹‰ä¸ºå‡†ã€‚
     */
    private Boolean getOperationQuality(Long operationId) {
        TechnologyOperation technologyOperation = technologyOperationMapper.selectById(operationId);
        return technologyOperation != null ? technologyOperation.getIsQuality() : null;
    private TechnologyOperation getOperation(Long operationId) {
        if (operationId == null) {
            return null;
        }
        return technologyOperationMapper.selectById(operationId);
    }
    private String buildProcessRouteCode(Long id) {
src/main/resources/application-dev.yml
@@ -254,8 +254,18 @@
  # æ˜¯å¦å…è®¸ç”Ÿæˆæ–‡ä»¶è¦†ç›–到本地(自定义路径),默认不允许
  allowOverwrite: false
# æ–‡ä»¶ä¸Šä¼ é…ç½®
file:
  temp-dir: D:/ruoyi/temp/uploads   # ä¸´æ—¶ç›®å½•
  upload-dir: D:/ruoyi/prod/uploads # æ­£å¼ç›®å½•
  temp-dir: D:/ruoyi/temp/uploads   # ä¸´æ—¶ç›®å½• åŽæœŸåˆ é™¤
  upload-dir: D:/ruoyi/prod/uploads # æ­£å¼ç›®å½• åŽæœŸåˆ é™¤
  path: D:/ruoyi/prod/uploads # ä¸Šä¼ ç›®å½•
  urlPrefix: /common # é“¾æŽ¥å‰ç¼€
  domain: http://127.0.0.1:7005 # åŸŸåå‰ç¼€
  expired: 120 # è¿‡æœŸæ—¶é—´(单位:分钟)
  useLimit: 10 # ä½¿ç”¨æ¬¡æ•°
  compress: true # æ˜¯å¦åŽ‹ç¼©
  needCompressSize: 10MB # åŽ‹ç¼©é˜ˆå€¼
  compressQuality: 0.5 # åŽ‹ç¼©è´¨é‡(0.0-1.0)
knowledge:
  one: D:\新疆大罗素企业产品体系说明文档.md
src/main/resources/mapper/account/AccountExpenseMapper.xml
@@ -45,7 +45,7 @@
            AND expense_method = #{accountExpense.expenseMethod}
        </if>
    </select>
    <select id="report" resultType="com.ruoyi.account.dto.AccountDto2">
    <select id="report" resultType="com.ruoyi.account.bean.dto.AccountDto2">
        SELECT
        sdd.dict_label typeName,
        sum(expense_money) account
src/main/resources/mapper/account/AccountIncomeMapper.xml
@@ -66,7 +66,7 @@
            AND income_method = #{accountIncome.incomeMethod}
        </if>
    </select>
    <select id="report" resultType="com.ruoyi.account.dto.AccountDto2">
    <select id="report" resultType="com.ruoyi.account.bean.dto.AccountDto2">
        SELECT
        sdd.dict_label typeName,
        ifnull(sum(income_money),0) account
src/main/resources/mapper/account/AccountSubjectMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
<?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.account.mapper.AccountSubjectMapper">
    <!-- é€šç”¨æŸ¥è¯¢æ˜ å°„结果 -->
    <resultMap id="BaseResultMap" type="com.ruoyi.account.pojo.AccountSubject">
        <id column="id" property="id" />
        <result column="subject_code" property="subjectCode" />
        <result column="subject_name" property="subjectName" />
        <result column="subject_type" property="subjectType" />
        <result column="balance_direction" property="balanceDirection" />
        <result column="status" property="status" />
        <result column="remark" property="remark" />
        <result column="create_user" property="createUser" />
        <result column="create_time" property="createTime" />
        <result column="update_user" property="updateUser" />
        <result column="update_time" property="updateTime" />
        <result column="dept_id" property="deptId" />
    </resultMap>
</mapper>
src/main/resources/mapper/account/SalesRefundAmountOrderMapper.xml
@@ -15,7 +15,7 @@
        <result column="create_user_id" property="createUserId" />
        <result column="update_user_id" property="updateUserId" />
    </resultMap>
    <select id="pageSalesRefundAmountOrderDto" resultType="com.ruoyi.account.dto.SalesRefundAmountOrderDto">
    <select id="pageSalesRefundAmountOrderDto" resultType="com.ruoyi.account.bean.dto.SalesRefundAmountOrderDto">
        select sl.sales_contract_no,
        sl.customer_contract_no,
        slp.specification_model,
src/main/resources/mapper/device/DeviceLedgerMapper.xml
@@ -88,7 +88,7 @@
        where id = #{id}
    </select>
    <select id="getDeviceTypeDistributionByYear"
            resultType="com.ruoyi.account.dto.DeviceTypeDetail"
            resultType="com.ruoyi.account.bean.dto.DeviceTypeDetail"
            parameterType="java.lang.Integer">
        SELECT
            `type`,
src/main/resources/mapper/procurementrecord/ReturnManagementMapper.xml
@@ -53,4 +53,30 @@
                 left join sales_ledger sl on si.sales_ledger_id = sl.id
        where rm.id = #{id}
    </select>
    <select id="listPageBySalesReturn" resultType="com.ruoyi.account.bean.vo.SalesReturnVo">
         select rm.id,
                rm.return_no,
                c.customer_name,
                si.shipping_no,
                rm.make_time,
                rm.refund_amount,
                rm.return_reason,
                rm.make_time,
                sl.sales_contract_no
        from return_management rm
                 left join shipping_info si on rm.shipping_id = si.id
                 left join customer c on rm.customer_id = c.id
                 left join sales_ledger sl on si.sales_ledger_id = sl.id
        where rm.status=1
            <if test="req.returnNo != null and req.returnNo != ''">
                and rm.return_no like concat('%',#{req.returnNo},'%')
            </if>
            <if test="req.customerName != null and req.customerName != ''">
                and c.customer_name like concat('%',#{req.customerName},'%')
            </if>
            <if test="req.startDate != null and req.endDate != null">
                AND DATE_FORMAT(rm.make_time, '%Y-%m-%d') BETWEEN #{startDate} AND #{endDate}
            </if>
         order by rm.id DESC
    </select>
</mapper>
src/main/resources/mapper/production/ProductionAccountMapper.xml
@@ -29,15 +29,19 @@
        pa.scheduling_user_id as schedulingUserId,
        pa.scheduling_user_name as schedulingUserName,
        cast(sum(
            ifnull(pa.work_hours, 0) * ifnull(pa.finished_num, 0) *
            case
                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
                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
            end
        ) as decimal(18,4)) as wages,
        cast(sum(ifnull(pa.finished_num, 0)) as decimal(18,4)) as finishedNum,
        cast(sum(ifnull(pa.work_hours, 0)) as decimal(18,4)) as workHours,
        cast(sum(ifnull(ppm.work_hour, 0)) as decimal(18,4)) as workHour,
        case
            when sum(ifnull(ppo.quantity, 0) + ifnull(ppo.scrapQty, 0)) = 0 then '0%'
            else concat(
src/main/resources/mapper/production/ProductionOperationTaskMapper.xml
@@ -29,6 +29,7 @@
               pm.model as model,
               pm.unit as unit,
               poro.operation_name as operationName,
               poro.type as type,
               IFNULL(scrapStat.scrapQty, 0) AS scrapQty,
        ROUND(IFNULL(pot.complete_quantity, 0) / NULLIF(pot.plan_quantity, 0) * 100, 2) AS completionStatus,
        CASE
src/main/resources/mapper/production/ProductionOrderRoutingOperationMapper.xml
@@ -13,6 +13,7 @@
        <result column="update_time" property="updateTime" />
        <result column="drag_sort" property="dragSort" />
        <result column="is_quality" property="isQuality" />
        <result column="type" property="type" />
        <result column="create_user" property="createUser" />
        <result column="dept_id" property="deptId" />
    </resultMap>
src/main/resources/mapper/production/ProductionPlanMapper.xml
@@ -53,6 +53,9 @@
                <if test="c.requiredDateStart != null and c.requiredDateEnd != null">
                    and pp.required_date between #{c.requiredDateStart} and #{c.requiredDateEnd}
                </if>
                <if test="c.salesContractNo != null and c.salesContractNo != ''">
                    and sl.sales_contract_no like concat('%', #{c.salesContractNo}, '%')
                </if>
            </if>
        </where>
        ORDER BY COALESCE(pp.id) DESC
src/main/resources/mapper/production/ProductionProductMainMapper.xml
@@ -100,14 +100,18 @@
               ifnull(ppo.scrap_qty, 0) as scrapQty,
               date(pa.scheduling_date) as schedulingDate,
               pa.scheduling_user_name as schedulingUserName,
               cast(ifnull(ppm.work_hour, 0) as decimal(18,4)) as workHour,
               cast(ifnull(pa.work_hours, 0) as decimal(18,4)) as workHours,
               cast(
                   ifnull(pa.work_hours, 0) * ifnull(pa.finished_num, 0) *
                   case
                       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
                       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
                   end
                   as decimal(18,4)
               ) as wages
        from production_account pa
src/main/resources/mapper/purchase/PurchaseReturnOrdersMapper.xml
@@ -18,16 +18,18 @@
        <result column="create_time" property="createTime" />
        <result column="update_time" property="updateTime" />
    </resultMap>
    <select id="listPage" resultType="com.ruoyi.purchase.vo.PurchaseReturnOrderVo">
    <sql id="getPurchaseReturnOrderHasAllInfoFormAndColumn">
        SELECT
        pro.*,
        sm.supplier_name as supplierName,
        pl.purchase_contract_number as purchaseContractNumber
            sm.supplier_name as supplier_name,
            pl.purchase_contract_number as purchase_contract_number
        FROM purchase_return_orders pro
        LEFT JOIN supplier_manage sm ON pro.supplier_id = sm.id
        LEFT JOIN purchase_ledger pl ON pl.id = pro.purchase_ledger_id
        where 1=1
    </sql>
    <select id="listPage" resultType="com.ruoyi.purchase.dto.PurchaseReturnOrderHasAllInfoDto">
        <include refid="getPurchaseReturnOrderHasAllInfoFormAndColumn"/>
        <where>
        <if test="params.no != null and params.no != '' ">
            AND pro.no LIKE CONCAT('%',#{params.no},'%')
        </if>
@@ -43,6 +45,12 @@
        <if test="params.createUser != null">
            AND pro.create_user = #{params.createUser}
        </if>
        </where>
        ORDER BY pro.create_time DESC
    </select>
    <select id="getPurchaseReturnOrderHasAllInfoById"
            resultType="com.ruoyi.purchase.dto.PurchaseReturnOrderHasAllInfoDto">
        <include refid="getPurchaseReturnOrderHasAllInfoFormAndColumn"/>
        where pro.id = #{id}
    </select>
</mapper>
src/main/resources/mapper/sales/InvoiceRegistrationProductMapper.xml
@@ -56,7 +56,7 @@
                AND T3.invoice_date = #{invoiceRegistrationProductDto.invoiceDate}
            </if>
        </where>
        ORDER BY T1.create_time DESC
        ORDER BY T1.create_time DESC, T1.id DESC
    </select>
    <select id="invoiceRegistrationProductPage" resultType="com.ruoyi.sales.dto.InvoiceRegistrationProductDto">
@@ -127,6 +127,6 @@
                %H:%i:%s')+interval 1 day
            </if>
        </where>
        ORDER BY T1.create_time DESC
        ORDER BY T1.create_time DESC, T1.id DESC
    </select>
</mapper>
src/main/resources/mapper/sales/ShipmentApprovalMapper.xml
ÎļþÒÑɾ³ý
src/main/resources/mapper/sales/ShippingInfoMapper.xml
@@ -87,4 +87,33 @@
        left join sales_ledger sl on si.sales_ledger_id = sl.id
        where si.status = '已发货' and sl.customer_name = #{customerName}
    </select>
    <select id="listPageByOutbound" resultType="com.ruoyi.account.bean.vo.SalesOutboundVo">
         SELECT
        sor.id,
        sor.outbound_batches,
        sl.customer_name,
        s.shipping_date,
        p.product_name,
        slp.specification_model,
        slp.stock_out_num,
        s.shipping_no,
        sl.sales_contract_no
        FROM shipping_info s
        LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
        LEFT JOIN sales_ledger_product slp ON s.sales_ledger_product_id = slp.id and slp.type = 1
        left join product_model pm on slp.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        left join stock_out_record sor on sor.record_id = s.id and sor.record_type='13'
        WHERE s.status='已发货'
        <if test="req.outboundBatches != null and req.outboundBatches != ''">
            AND sor.outbound_batches LIKE CONCAT('%',#{req.outboundBatches},'%')
        </if>
        <if test="req.customerName != null and req.customerName != ''">
            AND sl.customer_name LIKE CONCAT('%',#{req.customerName},'%')
        </if>
        <if test="req.startDate != null and req.endDate != null">
            AND s.shipping_date BETWEEN #{startDate} AND #{endDate}
        </if>
        order by sor.id DESC
    </select>
</mapper>
src/main/resources/mapper/sales/ShippingProductDetailMapper.xml
@@ -18,5 +18,14 @@
                 left join product p on p.id = pm.product_id
        where spd.shipping_info_id = #{id}
    </select>
    <select id="getDateilByShippingNo" resultType="com.ruoyi.sales.dto.ShippingProductDetailDto">
         select si.batch_no, pm.model as specification_model, p.product_name, spd.quantity as delivery_quantity
         from shipping_product_detail spd
                  left join shipping_info sp on sp.id = spd.shipping_info_id
                  left join stock_inventory si on si.id = spd.stock_inventory_id
                  left join product_model pm on pm.id = si.product_model_id
                  left join product p on p.id = pm.product_id
         where sp.shipping_no = #{shippingNo}
    </select>
</mapper>
src/main/resources/mapper/staff/StaffOnJobMapper.xml
@@ -6,14 +6,13 @@
        staff_on_job.*,
        sp.post_name as postName,
        sd.dept_name as deptName,
        t1.contract_start_time
        MIN(t1.contract_start_time) as contract_start_time,  -- å–最早合同开始时间
        MAX(t1.contract_end_time) as contract_end_time
        FROM staff_on_job
        LEFT JOIN
        sys_post sp ON sp.post_id = staff_on_job.sys_post_id
        LEFT JOIN
        sys_dept sd ON sd.dept_id = staff_on_job.sys_dept_id
        LEFT JOIN sys_post sp ON sp.post_id = staff_on_job.sys_post_id
        LEFT JOIN sys_dept sd ON sd.dept_id = staff_on_job.sys_dept_id
        LEFT JOIN staff_contract as t1 ON t1.staff_on_job_id = staff_on_job.id
        where 1=1
        WHERE 1=1
        <if test="staffOnJob.staffState != null">
            AND staff_state = #{staffOnJob.staffState}
        </if>
@@ -26,6 +25,7 @@
        <if test="staffOnJob.entryDateEnd != null and staffOnJob.entryDateEnd != '' ">
            AND contract_expire_time &lt;= DATE_FORMAT(#{staffOnJob.entryDateEnd},'%Y-%m-%d')
        </if>
        GROUP BY staff_on_job.id
    </select>
    <select id="staffOnJobList" resultType="com.ruoyi.staff.dto.StaffOnJobDto">
        SELECT
src/main/resources/mapper/technology/TechnologyRoutingOperationMapper.xml
@@ -12,6 +12,7 @@
        <result column="update_time" property="updateTime" />
        <result column="drag_sort" property="dragSort" />
        <result column="is_quality" property="isQuality" />
        <result column="type" property="type" />
        <result column="create_user" property="createUser" />
        <result column="dept_id" property="deptId" />
    </resultMap>