gaoluyang
3 天以前 d39e4f7d839167c16425086dfa2f8747bccfdff9
feat: 打通销售采购财务闭环并完成累计业务优化

财务与销售采购:
- 来票必须同时绑定采购订单与 MES 采购入库单,按两层剩余额度校验
- 开票申请绑定销售订单与 MES 销售出库单,审批后录入发票(发票号/日期/附件)
- 录入发票时填写合同款/预付款/到货款/验收款/质保金收款节点,落库回款计划
- 回款核销明细支持拆分到销售订单/销售出库/开票,审核后回写回款计划
- 新增客户应收对账、已发货未开票、未核销应收账龄、月度开票回款趋势
- 记账凭证增加来源单据关联(sourceType/sourceId/sourceNo)

累计改动:
- 售后工单流转与仓储退货、离职交接流程、通知公告附件记录类型
- Excel 必填表头标记、到货通知合同号、MES 入库/出库金额与 API
- 同步合并初始化 SQL、前端联调文档与业务可视化文档

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加38个文件
已修改128个文件
6427 ■■■■■ 文件已修改
docs/aftersales_frontend_integration.md 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/business.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json 572 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/roles.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/01-business-mindmap.mmd 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/07-after-sales-flow.mmd 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/10-business-object.mmd 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/purchase_flow_frontend_guide.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20261104.sql 245 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261105_arrival_notice_contract_no.sql 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261105_hrm_resignation_flow.sql 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/aftersales_ticket_flow_20261105.sql 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_user_handover_init.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/ExcelRequired.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/RequiredHeaderWriteHandler.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales-api/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApi.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales-api/src/main/java/cn/iocoder/yudao/module/aftersales/api/dto/AfterSaleWarehouseReturnCreateReqDTO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApiImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketFlowRespVO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSaveReqVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketFlowDO.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketFlowMapper.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketFlowService.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketFlowServiceImpl.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/event/BpmProcessInstanceStatusEvent.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/event/BpmProcessInstanceStatusEventListener.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/definition/BpmProcessDefinitionController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/convert/definition/BpmProcessDefinitionConvert.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/convert/task/BpmProcessInstanceConvert.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/attach/BpmBusinessAttachServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/pom.xml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/CrmFinanceReportController.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmCustomerStatementRespVO.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmFinanceTrendRespVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmShippedNotInvoicedRespVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmUnwrittenOffRespVO.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceEntryReqVO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceSaleOutRespVO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceSaveReqVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanSaveReqVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableItemRespVO.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/invoice/CrmInvoiceDO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableItemDO.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivablePlanDO.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableItemMapper.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/LogRecordConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/receivable/CrmPaymentStageEnum.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/receivable/CrmReceivableBizTypeEnum.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/permission/core/aop/CrmPermissionAspect.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/finance/CrmFinanceReportService.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/finance/CrmFinanceReportServiceImpl.java 434 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java 352 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanServiceImpl.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/util/CrmPermissionUtils.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApi.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApiImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseRequestController.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpSupplierStatementController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceItemReceiptRespVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/statement/ErpSupplierStatementSummaryReqVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/statement/ErpSupplierStatementSummaryRespVO.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceDO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestServiceImpl.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpSupplierStatementService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpSupplierStatementServiceImpl.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/voucher/vo/FinVoucherPageReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/voucher/vo/FinVoucherRespVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/voucher/vo/FinVoucherSaveReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/voucher/FinVoucherDO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/mysql/voucher/FinVoucherMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/HrmResignationApplicationController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmResignationApplicationRespVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeStatisticsRespVO.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/handover/HrmUserHandoverDO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/approval/HrmResignationApplicationMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/job/approval/HrmResignationAutoLeaveJob.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmResignationApplicationService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmResignationApplicationServiceImpl.java 392 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 351 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/handover/HrmUserHandoverService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/handover/HrmUserHandoverServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/MesWmItemReceiptApi.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/dto/MesWmItemReceiptLineRespDTO.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/dto/MesWmItemReceiptRespDTO.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/MesWmProductSalesApi.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/dto/MesWmProductSalesLineRespDTO.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/dto/MesWmProductSalesRespDTO.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/pom.xml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/MesWmItemReceiptApiImpl.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/MesWmProductSalesApiImpl.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/checkplan/vo/machinery/MesDvCheckPlanMachineryRespVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/checkrecord/vo/MesDvCheckRecordRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryImportExcelVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryImportRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryPageReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachinerySaveReqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/maintenrecord/vo/MesDvMaintenRecordRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/repair/vo/MesDvRepairRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/md/workstation/vo/machine/MesMdWorkstationMachineRespVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/MesWmArrivalNoticeController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/vo/MesWmArrivalNoticePageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/vo/MesWmArrivalNoticeRespVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/vo/MesWmArrivalNoticeSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/dv/machinery/MesDvMachineryDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/arrivalnotice/MesWmArrivalNoticeDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/itemreceipt/MesWmItemReceiptLineDO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesLineDO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/arrivalnotice/MesWmArrivalNoticeMapper.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/itemreceipt/MesWmItemReceiptMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesLineMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/md/autocode/MesMdAutoCodeRuleCodeEnum.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/dv/machinery/MesDvMachineryServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImpl.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImpl.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnvendor/MesWmReturnVendorServiceImpl.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/notice/NoticeController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/notice/vo/NoticeRespVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/notice/vo/NoticeSaveReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/notice/NoticeServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/aftersales_frontend_integration.md
@@ -6,28 +6,28 @@
|------|---------|------|
| å”®åŽå·¥å•列表 | `/aftersales/ticket` | å·¥å•列表、搜索、状态筛选 |
| å”®åŽå·¥å•详情/编辑 | `/aftersales/ticket/detail?id=:id` | æ–°å»º/编辑工单、查看详情 |
| é€€è´§ç”³è¯·åˆ—表 | `/aftersales/return` | é€€è´§ç”³è¯·åˆ—表、搜索 |
| é€€è´§ç”³è¯·è¯¦æƒ… | `/aftersales/return/detail?id=:id` | é€€è´§è¯¦æƒ…、审核操作 |
> é€€è´§ç”³è¯·é¡µé¢å·²ä¸‹çº¿ï¼Œé€€è´§ç»Ÿä¸€ç”±ä»“储物流模块发起;售后工单只保留退货状态联动。
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
### æµç¨‹ä¸€ï¼šä¸€èˆ¬é—®é¢˜å·¥å•
```
创建工单 â†’ åˆ†é…å¤„理人 â†’ é—®é¢˜åˆ¤å®šï¼ˆä¸€èˆ¬é—®é¢˜ï¼‰â†’ å¤„理 â†’ å®Œç»“ â†’ å…³é—­
创建工单(必选处理人,直接进入处理中)→ é—®é¢˜åˆ¤å®šï¼ˆä¸€èˆ¬é—®é¢˜ï¼‰â†’ å¤„理 â†’ å®Œç»“ â†’ å…³é—­
```
### æµç¨‹äºŒï¼šé€€è´§é—®é¢˜å·¥å•
```
创建工单 â†’ åˆ†é…å¤„理人 â†’ é—®é¢˜åˆ¤å®šï¼ˆé€€è´§é—®é¢˜ï¼‰
  â†’ åˆ›å»ºé€€è´§ç”³è¯· â†’ é”€å”®å®¡æ ¸ â†’ [MES退货流程自动执行] â†’ é€€æ¬¾/换货完成 â†’ å®Œç»“ â†’ å…³é—­
创建工单(必选处理人,直接进入处理中)→ é—®é¢˜åˆ¤å®šï¼ˆé€€è´§é—®é¢˜ï¼‰
  â†’ ä»“储物流发起销售退货 â†’ é”€å”®å®¡æ ¸ â†’ [MES退货流程自动执行] â†’ é€€æ¬¾/换货完成 â†’ å®Œç»“ â†’ å…³é—­
```
### æµç¨‹ä¸‰ï¼šç»´ä¿®é—®é¢˜å·¥å•
```
创建工单 â†’ åˆ†é…å¤„理人 â†’ é—®é¢˜åˆ¤å®šï¼ˆç»´ä¿®é—®é¢˜ï¼‰
创建工单(必选处理人,直接进入处理中)→ é—®é¢˜åˆ¤å®šï¼ˆç»´ä¿®é—®é¢˜ï¼‰
  â†’ åˆ›å»ºç»´ä¿®è®°å½• â†’ ç»´ä¿®å®Œæˆ â†’ å®Œç»“ â†’ å…³é—­
```
@@ -53,11 +53,10 @@
| æ–¹æ³• | è·¯å¾„ | æƒé™ | è¯´æ˜Ž |
|------|------|------|------|
| POST | `/aftersales/ticket/create` | `aftersales:ticket:create` | åˆ›å»ºå·¥å• |
| PUT | `/aftersales/ticket/update` | `aftersales:ticket:update` | æ›´æ–°å·¥å•(仅草稿状态可更新) |
| PUT | `/aftersales/ticket/update` | `aftersales:ticket:update` | æ›´æ–°å·¥å•(草稿/处理中状态可更新) |
| DELETE | `/aftersales/ticket/delete` | `aftersales:ticket:delete` | åˆ é™¤å·¥å•(仅草稿/已取消状态可删除) |
| GET | `/aftersales/ticket/get` | `aftersales:ticket:query` | èŽ·å–å·¥å•è¯¦æƒ… |
| GET | `/aftersales/ticket/page` | `aftersales:ticket:query` | åˆ†é¡µæŸ¥è¯¢å·¥å• |
| POST | `/aftersales/ticket/assign` | `aftersales:ticket:update` | åˆ†é…å¤„理人 |
| POST | `/aftersales/ticket/classify` | `aftersales:ticket:update` | é—®é¢˜åˆ¤å®š |
| PUT | `/aftersales/ticket/resolve` | `aftersales:ticket:update` | å®Œç»“工单 |
| PUT | `/aftersales/ticket/close` | `aftersales:ticket:update` | å…³é—­å·¥å• |
@@ -128,7 +127,7 @@
#### 1.2 æ›´æ–°å·¥å• `PUT /aftersales/ticket/update`
请求参数与创建相同,额外需要 `id` å­—段。仅草稿状态可更新。
请求参数与创建相同,额外需要 `id` å­—段。仅草稿或处理中状态可更新。
#### 1.3 åˆ é™¤å·¥å• `DELETE /aftersales/ticket/delete`
@@ -251,16 +250,7 @@
**注意:** åˆ†é¡µåˆ—表响应中的每条记录结构与详情接口一致(含 `items` å’Œå…³è”名称字段),但列表场景下 `items` å¯ä¸ºç©ºã€‚
#### 1.6 åˆ†é…å¤„理人 `POST /aftersales/ticket/assign`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | å·¥å•编号(Query å‚数) |
| handlerUserId | Long | æ˜¯ | å¤„理人用户编号(Query å‚数) |
**业务规则:** ä»…草稿、处理中状态可分配。分配后状态自动变为"处理中"。
#### 1.7 é—®é¢˜åˆ¤å®š `POST /aftersales/ticket/classify`
#### 1.6 é—®é¢˜åˆ¤å®š `POST /aftersales/ticket/classify`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
@@ -269,7 +259,7 @@
| issueCategory | String | å¦ | é—®é¢˜åˆ†ç±»ï¼ˆå¦‚"外观缺陷"、"功能故障"等) |
| severityLevel | Integer | å¦ | ä¸¥é‡ç¨‹åº¦ï¼š0-轻微 1-一般 2-严重 3-致命 |
**业务规则:** ä»…处理中状态可判定。判定为退货问题后,应引导用户创建退货申请。
**业务规则:** ä»…处理中状态可判定。判定为退货问题后,由仓储物流模块发起销售退货。
#### 1.8 å®Œç»“工单 `PUT /aftersales/ticket/resolve`
@@ -432,7 +422,7 @@
| å€¼ | åç§° | è¯´æ˜Ž |
|----|------|------|
| 0 | è‰ç¨¿ | å¯ç¼–辑、可删除、可取消 |
| 10 | å¤„理中 | å¯åˆ†é…ã€å¯åˆ¤å®šã€å¯å–消 |
| 10 | å¤„理中 | å¯ç¼–辑、可判定、可完结、可取消 |
| 30 | å¾…退货 | é€€è´§æµç¨‹è¿›è¡Œä¸­ |
| 40 | å¾…退款/换货 | ç­‰å¾…退款或换货完成 |
| 50 | å·²å®Œç»“ | å¯å…³é—­ |
@@ -525,7 +515,7 @@
| issueDescription | åŸºæœ¬ä¿¡æ¯ | é—®é¢˜æè¿° |
| severityLevel | åŸºæœ¬ä¿¡æ¯ | ä¸¥é‡ç¨‹åº¦ï¼ˆåˆ¤å®šåŽæ˜¾ç¤ºï¼‰ |
| ownerUserId / ownerUserName | åŸºæœ¬ä¿¡æ¯ | è´Ÿè´£äººï¼ˆåˆ›å»ºæ—¶è‡ªåŠ¨è®¾ä¸ºå½“å‰ç”¨æˆ·ï¼‰ |
| handlerUserId / handlerUserName | åŸºæœ¬ä¿¡æ¯ | å¤„理人(分配后显示) |
| handlerUserId / handlerUserName | åŸºæœ¬ä¿¡æ¯ | å¤„理人(新增时必选,创建后显示) |
| returnId | åŸºæœ¬ä¿¡æ¯ | å…³è”退货申请(可点击跳转) |
| repairId | åŸºæœ¬ä¿¡æ¯ | å…³è”维修记录(可点击跳转) |
| resolvedTime | åŸºæœ¬ä¿¡æ¯ | è§£å†³æ—¶é—´ï¼ˆå®Œç»“后显示) |
@@ -580,21 +570,12 @@
| æ“ä½œ | è‰ç¨¿(0) | å¤„理中(10) | å¾…退货(30) | å¾…退款(40) | å·²å®Œç»“(50) | å·²å…³é—­(60) | å·²å–消(99) |
|------|---------|------------|------------|------------|------------|------------|------------|
| ç¼–辑 | âœ… | âŒ | âŒ | âŒ | âŒ | âŒ | âŒ |
| ç¼–辑 | âœ… | âœ… | âŒ | âŒ | âŒ | âŒ | âŒ |
| åˆ é™¤ | âœ… | âŒ | âŒ | âŒ | âŒ | âŒ | âœ… |
| åˆ†é…å¤„理人 | âœ… | âœ… | âŒ | âŒ | âŒ | âŒ | âŒ |
| é—®é¢˜åˆ¤å®š | âŒ | âœ… | âŒ | âŒ | âŒ | âŒ | âŒ |
| åˆ›å»ºé€€è´§ç”³è¯· | âŒ | âœ…(判定为退货后) | âŒ | âŒ | âŒ | âŒ | âŒ |
| å®Œç»“ | âŒ | âœ… | âœ… | âœ… | âŒ | âŒ | âŒ |
| å…³é—­ | âŒ | âŒ | âŒ | âŒ | âœ… | âŒ | âŒ |
| å–消 | âœ… | âœ… | âŒ | âŒ | âŒ | âŒ | âŒ |
### é€€è´§ç”³è¯·æ“ä½œæŒ‰é’®æ˜¾éšè§„则
| æ“ä½œ | è‰ç¨¿(0) | å®¡æ ¸ä¸­(10) | å·²åŒæ­¥MES(20) | æ£€éªŒä¸­(30) | æ£€éªŒå®Œæˆ(40) | å·²å…¥åº“(50) | é€€æ¬¾å®Œæˆ(60) |
|------|---------|------------|---------------|------------|--------------|------------|--------------|
| å®¡æ ¸é€šè¿‡ | âœ… | âŒ | âŒ | âŒ | âŒ | âŒ | âŒ |
| æŸ¥çœ‹è¯¦æƒ… | âœ… | âœ… | âœ… | âœ… | âœ… | âœ… | âœ… |
### åˆ†é¡µæŸ¥è¯¢æ•°æ®æƒé™è¯´æ˜Ž
@@ -628,7 +609,7 @@
1. **明细行更新策略**:更新工单时,明细行采用"先删后增"策略,前端必须传入完整的明细行列表(包括未修改的行),否则会导致明细行丢失。
2. **单号生成**:工单单号(AS前缀)和退货单号(RT前缀)由后端自动生成,前端不需要传入。
3. **状态流转**:所有状态变更操作(分配、判定、完结、关闭、取消、审核)后端都会校验当前状态是否允许,前端应根据按钮显隐规则控制操作入口。
3. **状态流转**:所有状态变更操作(判定、完结、关闭、取消、审核)后端都会校验当前状态是否允许,前端应根据按钮显隐规则控制操作入口。
4. **退货审核后自动同步 MES**:审核通过退货申请后,后端自动调用 MES åˆ›å»ºé”€å”®é€€è´§å•,后续 MES æµç¨‹ï¼ˆè´¨æ£€â†’入库→退款)由回调自动推进状态,前端只需轮询刷新查看最新状态。
5. **数据带入**:创建退货申请时,必须从工单详情页跳转并带入 `ticketId`、`customerId`、`saleOrderId` ä»¥åŠå·¥å•明细行中标记为"需要退货"的行。
6. **权限控制**:所有接口都有 `@PreAuthorize` æƒé™æ ¡éªŒï¼Œå‰ç«¯èœå•和按钮需要对应配置权限标识。
docs/project-business/data/business.json
@@ -59,7 +59,7 @@
    ],
    [
      "售后如何闭环",
      "工单判定退货 â†’ é€€è´§ç”³è¯·å®¡æ ¸ â†’ è‡ªåŠ¨å»º MES é”€å”®é€€è´§å• â†’ RQC è´¨æ£€/入库回调 â†’ é€€æ¬¾å›žè°ƒ â†’ å·¥å•完结关闭"
      "工单判定退货 â†’ ä»“储物流发起销售退货(原售后退货申请页面已下线) â†’ å»º MES é”€å”®é€€è´§å• â†’ RQC è´¨æ£€/入库回调 â†’ é€€æ¬¾å›žè°ƒ â†’ å·¥å•完结关闭"
    ],
    [
      "AI åœ¨å“ªé‡Œå‚与",
docs/project-business/data/flows.json
@@ -1,78 +1,500 @@
{
  "flows": [
    {"id": "sales", "name": "销售流程", "mermaid": "mermaid/02-sales-flow.mmd", "color": "#4f9cff",
     "summary": "线索转化客户 â†’ å•†æœºæŽ¨è¿› â†’ æŠ¥ä»·(可AI识别) â†’ åˆåŒ BPM å®¡æ‰¹ â†’ ç”Ÿæˆ ERP é”€å”®è®¢å•并审批 â†’ éœ€ç”Ÿäº§è¡Œè‡ªåŠ¨å»º MPS â†’ å‘货通知 â†’ é”€å”®å‡ºåº“提交(需检验行转待检测) â†’ ç‹¬ç«‹å½•å…¥ OQC åŽæŒ‰ç‰©æ–™+盘号+数量关联回写 â†’ æ‹£è´§å‘运 â†’ å¼€ç¥¨(BPM) â†’ å›žæ¬¾(先开票校验)。",
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR é¢„å¡«),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同来源:手工新建 / æŠ¥ä»·å•转入 / Excel å¯¼å…¥(一合同多产品明细行);提交 BPM å®¡æ‰¹(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction æ˜Žç»†åˆ›å»º MPS", "roles": ["销售", "计划员"]},
       {"stage": "交付", "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“提交:有 oqcCheckFlag è¡Œè½¬å¾…检测,提交时不再自动建 OQC;质检员独立录入并完成 OQC åŽï¼Œåœ¨æ£€éªŒå•详情按物料+盘号+数量一致选择待检出库行关联,全部待检行合格后出库单转待拣货 â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库", "roles": ["仓管", "质检"]},
       {"stage": "回款", "action": "开票 BPM(累计≤合同额) â†’ å›žæ¬¾å¼ºåˆ¶å…ˆå¼€ç¥¨(累计≤合同剩余与开票剩余)", "roles": ["财务", "销售"]}]},
    {"id": "purchase", "name": "采购流程", "mermaid": "mermaid/03-purchase-flow.mmd", "color": "#5fc79e",
     "summary": "供应商档案(多联系人/多银行·含银行行号) â†’ è¯¢ä»·æ¯”ä»·(产品×供应商横向报价,按产品选中标) â†’ ç¡®è®¤åŽæŒ‰ä¸­æ ‡ä¾›åº”商拆采购订单草稿并走现有 BPM â†’ ç¡®è®¤æ”¶è´§ç”Ÿæˆ MES åˆ°è´§é€šçŸ¥ â†’ IQC(合格/特采入库·退货/报废) â†’ é‡‡è´­å…¥åº“回写订单与申请 â†’ æ¥ç¥¨(AI OCR) â†’ ä»˜æ¬¾å• BPM â†’ ä¾›åº”商往来对账;投标保证金台账独立记录中标与退回。",
     "steps": [
       {"stage": "寻源", "action": "供应商档案维护多个联系人与多个银行账户(含银行行号);新增询价单时一并录入产品×参与供应商报价并选定每个产品的中标供应商,列表直接确认后按中标供应商生成采购订单草稿;投标保证金台账记录是否中标与退回状态/金额/日期", "roles": ["采购员", "供应商管理员"]},
       {"stage": "申请", "action": "采购申请(CGSQ)提交 BPM,驳回可重置回草稿;通过且有供应商自动生成订单", "roles": ["采购员", "审批人"]},
       {"stage": "下单", "action": "采购订单(CGDD)自动提交 BPM(15→20)", "roles": ["采购员"]},
       {"stage": "收货", "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料自动创建 IQC", "roles": ["仓管", "IQC è´¨æ£€å‘˜"]},
       {"stage": "入库", "action": "IQC åˆæ ¼/特采→采购入库(待上架→待执行→完成)库存+,回写订单 inCount/inStatus ä¸Žç”³è¯·æ˜Žç»†", "roles": ["仓管"]},
       {"stage": "退货", "action": "IQC ä¸åˆæ ¼é€€è´§â†’供应商退货出库(选料锁定到订单行);报废走 NCR", "roles": ["仓管"]},
       {"stage": "付款", "action": "采购来票(FPD, AI OCR, â‰¤è®¢å•总价) â†’ ä»˜æ¬¾å•(FKD å…³è”来票+附件, â‰¤æ¥ç¥¨é‡‘额) BPM å®¡æ‰¹", "roles": ["财务", "审批人"]},
       {"stage": "对账", "action": "供应商往来按供应商汇总:左侧采购订单流水含已开票/未开票金额,右侧来票流水含开票金额/已付/剩余应付并附付款流水,顶部汇总累计采购/开票/付款/未付余额", "roles": ["财务", "采购员"]}]},
    {"id": "production", "name": "生产流程", "mermaid": "mermaid/04-production-flow.mmd", "color": "#e0b34f",
     "summary": "销售订单驱动 MPS â†’ ä¸‹å‘工单 â†’ æŒ‰å·¥è‰ºè·¯çº¿æ‹†å·¥åºä»»åŠ¡ â†’ é¢†æ–™/线边备料 â†’ æŠ¥å·¥(工序顺序硬校验+BPM) â†’ å€’冲消耗/产出入库/IPQC â†’ æœ«é“工序回写进度 â†’ è‡ªåŠ¨å®Œå·¥ â†’ ProductionFinishedEvent å›žå†™é”€å”®è®¢å•。",
     "steps": [
       {"stage": "计划", "action": "MPS åˆ›å»º(手工或销售订单自动)→下发或合并下发为工单", "roles": ["计划员"]},
       {"stage": "派工", "action": "工单确认按工艺路线逐工序生成任务,可指派/暂停/取消", "roles": ["车间主任"]},
       {"stage": "备料", "action": "生产领料单出库或线边虚拟库备料", "roles": ["仓管"]},
       {"stage": "报工", "action": "提交报工:上一道工序未报满直接拦截;启用 BPM èµ°å®¡æ‰¹", "roles": ["操作工"]},
       {"stage": "联动", "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→报工转待检验并生成 PENDING äº§å‡ºè¡Œï¼Œç­‰å¾…质检员独立录入 IPQC;关键非检→直接产出入库", "roles": ["系统自动"]},
       {"stage": "检验", "action": "已完成 IPQC åœ¨è¯¦æƒ…页按物料+盘号+数量一致选择待检产出行关联:回写报工完成并拆分合格/不合格产出入库;未关联的已完成检验单不回写", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "完工", "action": "仅末道工序回写工单已生产数量,达标自动完成;MPS ç´¯è®¡åˆ¤æ–­å®Œæˆå¹¶å‘事件给销售订单", "roles": ["系统自动"]}]},
    {"id": "quality", "name": "质量流程", "mermaid": "mermaid/05-quality-flow.mmd", "color": "#e06c6c",
     "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + å®Œæˆæ—¶æŒ‰åˆæ ¼/不合格数量自动判定(1 æ ¡éªŒé€šè¿‡/2 æ ¡éªŒä¸é€šè¿‡) + NCR è¯„审处置关闭;IPQC å®ŒæˆåŽæŒ‰ç‰©æ–™+盘号+数量关联报工待检行,OQC å®ŒæˆåŽæŒ‰åŒæ ·è§„则关联销售出库待检行;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单;检验单可由智能质检报告平台一键出件(generate-from-qc:数据从 MES å–,五道校验后渲染落库)。",
     "steps": [
       {"stage": "来料", "action": "到货通知/外协入库→IQC,结论回写推进待入库或触发退货", "roles": ["IQC è´¨æ£€å‘˜"]},
       {"stage": "过程", "action": "报工质检工序产出行待检→质检员独立录入 IPQC;完成后按物料+盘号+数量一致关联报工待检行,合格拆行入库、不合格生成 NCR", "roles": ["IPQC è´¨æ£€å‘˜"]},
       {"stage": "出货", "action": "销售出库提交后行转待检测→质检员独立录入 OQC;完成后按物料+盘号+数量一致关联出库待检行,全部合格后出库单进入待拣货", "roles": ["OQC è´¨æ£€å‘˜"]},
       {"stage": "退货", "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后", "roles": ["RQC è´¨æ£€å‘˜"]},
       {"stage": "导入", "action": "IQC/IPQC/OQC ä¸‰åˆ—表各支持 Excel æ‰¹é‡å¯¼å…¥(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC ç‰©æ–™ç”±å·¥å•推导,模板不填),合格+不合格=检测/接收数量、IPQC å·¥åºŸ+料废+其他废品≤不合格、OQC å®¢æˆ·é¡»åœ¨æœ¬äººæ•°æ®æƒé™å†…;逐行导入部分成功,失败行进 failureList å¸¦è¡Œå·ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œï¼ŒæˆåŠŸå•ä¸€å¾‹å¾…æ£€éªŒ0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC ç‰©æ–™+供应商+供应商批次+来料日期、IPQC ç”Ÿäº§å·¥å•+检验类型+工位+检测日期、OQC ç‰©æ–™+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作", "roles": ["质检员", "IQC è´¨æ£€å‘˜", "IPQC è´¨æ£€å‘˜", "OQC è´¨æ£€å‘˜"]},
       {"stage": "不合格", "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭", "roles": ["质量工程师"]},
       {"stage": "出件", "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) æ¸²æŸ“ HTML å¹¶å†»ç»“数据快照,编号 QR+日期+4位流水按天独立,preview åªæ¸²æŸ“不落库不消耗编号,regenerate ç”¨å†»ç»“快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF ç”±åŽç«¯è°ƒ Chromium æ‰“印实例已存的 HTML åŽå½’档附件中心;②generate-from-qc ç”±è´¨æ£€å•直接出件(数据经 MesQcReportApi ä»Ž MES å–,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText ä¸Žå¼•擎判定并存,响应回 undecidableCount å¾…判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份", "roles": ["质检员"]},
       {"stage": "建模板", "action": "AI å¯¼å…¥ï¼šä¸Šä¼ æ—¢æœ‰æ£€éªŒæŠ¥å‘Šæ–‡ä»¶(扫描件/照片、电子版 PDF、Word/Excel) â†’ è¯†åˆ«æˆæ¨¡æ¿è‰ç¨¿(不落库) â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤è°ƒæ•´åŽä¿å­˜ä¸ºç‰ˆæœ¬ï¼›.doc/.xls æ—§æ ¼å¼ä¸æ”¯æŒ", "roles": ["质检员", "质量工程师"]}]},
    {"id": "warehouse", "name": "仓储流程", "mermaid": "mermaid/06-warehouse-flow.mmd", "color": "#7e6bd6",
     "summary": "15 ç±»å•据驱动库存台账(六元组、四量)变动,全部经事务流水留痕;支持调拨在途、盘点调整、批次/SN è¿½æº¯ã€åº“存占用与四级冻结、线边虚拟库负库存;对外 WmsStockApi ä¾› ERP æ”¶å‘。",
     "steps": [
       {"stage": "入库", "action": "采购入库/生产入库/产出入库/退货入库/外协入库/杂项入库 â†’ IN äº‹åŠ¡ï¼Œæ‰¹æ¬¡æ··æ”¾æ ¡éªŒã€å››çº§å†»ç»“æ ¡éªŒ", "roles": ["仓管"]},
       {"stage": "出库", "action": "销售出库/领料/消耗/外协发料/供应商退货/杂项出库 â†’ OUT äº‹åŠ¡ï¼ŒcheckFlag æŽ§åˆ¶å¯å¦è´Ÿåº“å­˜", "roles": ["仓管", "系统自动"]},
       {"stage": "移库", "action": "内部/外部调拨成对 MOVE äº‹åŠ¡ï¼Œåœ¨é€”æ•°é‡è·Ÿè¸ª", "roles": ["仓管"]},
       {"stage": "盘点", "action": "方案→任务→明细行→结果,盘盈 ADJUST_IN、盘亏 ADJUST_OUT,可冻结与盲盘", "roles": ["盘点员"]},
       {"stage": "追溯", "action": "批次/SN(uuid)/条码(18ç±»)/装箱嵌套支撑正反向追溯", "roles": ["质量", "售后"]}]},
    {"id": "aftersales", "name": "售后流程", "mermaid": "mermaid/07-after-sales-flow.mmd", "color": "#c78bd6",
     "summary": "工单(六态+问题判定) â†’ ç»´ä¿®ä¸‰ç±»åž‹æˆ–退货申请 â†’ å®¡æ ¸è‡ªåŠ¨å»º MES é”€å”®é€€è´§å• â†’ RQC è´¨æ£€/入库/退款三回调逐级推进 â†’ å·¥å•完结关闭。",
     "steps": [
       {"stage": "受理", "action": "人工录入工单(关联客户/订单/合同),分配处理人进入处理中", "roles": ["客服"]},
       {"stage": "判定", "action": "问题判定: ä¸€èˆ¬/ç»´ä¿®/退货,严重度 0-3", "roles": ["售后工程师"]},
       {"stage": "ç»´ä¿®", "action": "现场/返厂/远程维修,费用与结果记录,完成联动工单完结", "roles": ["维修工程师"]},
       {"stage": "退货", "action": "退货申请审核→自动建 MES é”€å”®é€€è´§å•(RQC)→质检/入库回调→退款回调→关闭", "roles": ["售后", "质检", "财务"]}]},
    {"id": "finance", "name": "会计核算流程", "mermaid": "mermaid/12-finance-flow.mmd", "color": "#3ec9a7",
     "summary": "独立核算模块 yudao-module-finance:会计科目建账 â†’ è®°è´¦å‡­è¯å½•å…¥(借贷平衡校验) â†’ è¿‡è´¦ â†’ æ€»è´¦/明细账 â†’ èµ„产计提折旧与摊销 â†’ è´¢åŠ¡æŠ¥è¡¨çœ‹æ¿å–æ•° ERP å·²å®¡æ ¸å•据。",
     "steps": [
       {"stage": "建账", "action": "维护会计科目 tree(subjectCode å”¯ä¸€ï¼Œå¯å»ºä¸‹çº§),首次可导入约 68 æ¡ä¸€çº§ç§‘目种子", "roles": ["会计"]},
       {"stage": "è®°è´¦", "action": "录入凭证(字号留空自动生成)+分录,分录逐行选科目并填借/贷;校验至少一行、借贷都不得同时大于0、合计相等且大于0", "roles": ["会计"]},
       {"stage": "过账", "action": "未过账0 â†’ post è¿‡è´¦ â†’ å·²è¿‡è´¦10(不可再修改);未过账0 æˆ– å·²è¿‡è´¦10 å‡å¯ cancel ä½œåºŸ â†’ å·²ä½œåºŸ20", "roles": ["会计", "财务主管"]},
       {"stage": "查账", "action": "总账按期间逐月汇总、明细账逐条分录并结转余额,两者只统计已过账10的凭证,并支持导出", "roles": ["会计", "财务主管"]},
       {"stage": "资产", "action": "固定资产(1在用/2闲置/3维修中/4已报废)与无形资产(1在用/2已失效/3已摊销完)计提:传 ids æŒ‰é€‰ä¸­è®¡æï¼Œids ä¸ºç©ºå¯¹å…¨éƒ¨æœªæŠ¥åºŸ/未失效资产计提一个月", "roles": ["资产会计"]},
       {"stage": "报表", "action": "看板汇总资产与凭证时点数 + ç» ErpFinanceStatisticsApi å– ERP å·²å®¡æ ¸æ”¶è´§ä»˜æ¬¾ä¸Žè´­é”€é‡‘额,输出月度收支趋势", "roles": ["财务主管", "管理层"]}]},
    {"id": "ai", "name": "AI ä¸šåŠ¡æµç¨‹", "mermaid": "mermaid/11-ai-business-flow.mmd", "color": "#56c8d8",
     "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。智能体物料导入为规则+Java é¢„检链路,经人工确认后幂等提交。",
     "steps": [
       {"stage": "录入提效", "action": "发票 OCR(图片多模态/PDF文本)、报价单 OCR â†’ ç»“构化预填", "roles": ["采购员", "销售"]},
       {"stage": "主数据导入", "action": "上传物料 CSV/XLSX â†’ Python è§£æžå€™é€‰ â†’ Java åªè¯»é¢„检分类/单位/重复编码 â†’ äººå·¥ä¿®è®¢ç¡®è®¤ â†’ å¹‚等提交 â†’ é€è¡Œå›žæ‰§ï¼›åªæ–°å»ºã€ä¸æ›´æ–°ã€ä¸åŒæ­¥ MES", "roles": ["物料管理员"]},
       {"stage": "辅助判定", "action": "质检单+指标+缺陷 â†’ å»ºè®®åˆæ ¼/特采/退货/报废+理由", "roles": ["质检员"]},
       {"stage": "决策预测", "action": "缺料/生产时长/生产风险/交付四类预测供计划排产参考", "roles": ["计划员"]},
       {"stage": "模板识别", "action": "上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) â†’ åˆ†é€šé“抽取(有文本层走文本、扫描版逐页转图) â†’ å¤§æ¨¡åž‹æŒ‰å‰ç«¯ä¸Šä¼ çš„组件清单产出扁平组件草稿 â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤åŽä¿å­˜ä¸ºæ¨¡æ¿ç‰ˆæœ¬", "roles": ["质检员", "质量工程师"]},
       {"stage": "知识检索", "action": "文档→切片→Milvus å‘量→检索接口(预留)", "roles": ["—"]}]}
    {
      "id": "sales",
      "name": "销售流程",
      "mermaid": "mermaid/02-sales-flow.mmd",
      "color": "#4f9cff",
      "summary": "线索转化客户 â†’ å•†æœºæŽ¨è¿› â†’ æŠ¥ä»·(可AI识别) â†’ åˆåŒ BPM å®¡æ‰¹ â†’ ç”Ÿæˆ ERP é”€å”®è®¢å•并审批 â†’ éœ€ç”Ÿäº§è¡Œè‡ªåŠ¨å»º MPS â†’ å‘货通知 â†’ é”€å”®å‡ºåº“提交(需检验行转待检测) â†’ ç‹¬ç«‹å½•å…¥ OQC åŽæŒ‰ç‰©æ–™+盘号+数量关联回写 â†’ æ‹£è´§å‘运 â†’ å¼€ç¥¨(BPM) â†’ å›žæ¬¾(先开票校验)。",
      "steps": [
        {
          "stage": "获客",
          "action": "线索转化客户、建联系人、立项商机",
          "roles": [
            "销售内勤"
          ]
        },
        {
          "stage": "报价",
          "action": "商机赢单后建报价单(AI OCR é¢„å¡«),转合同",
          "roles": [
            "销售内勤"
          ]
        },
        {
          "stage": "签约",
          "action": "合同来源:手工新建 / æŠ¥ä»·å•转入 / Excel å¯¼å…¥(一合同多产品明细行);提交 BPM å®¡æ‰¹(可选流程定义),通过=20",
          "roles": [
            "销售",
            "审批人"
          ]
        },
        {
          "stage": "接单",
          "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction æ˜Žç»†åˆ›å»º MPS",
          "roles": [
            "销售",
            "计划员"
          ]
        },
        {
          "stage": "交付",
          "action": "发货通知审核(占用)→ é”€å”®å‡ºåº“提交:有 oqcCheckFlag è¡Œè½¬å¾…检测,提交时不再自动建 OQC;质检员独立录入并完成 OQC åŽï¼Œåœ¨æ£€éªŒå•详情按物料+盘号+数量一致选择待检出库行关联,全部待检行合格后出库单转待拣货 â†’ å‘运 â†’ å‡ºåº“回写部分/全部出库",
          "roles": [
            "仓管",
            "质检"
          ]
        },
        {
          "stage": "回款",
          "action": "开票 BPM(累计≤合同额) â†’ å›žæ¬¾å¼ºåˆ¶å…ˆå¼€ç¥¨(累计≤合同剩余与开票剩余)",
          "roles": [
            "财务",
            "销售"
          ]
        }
      ]
    },
    {
      "id": "purchase",
      "name": "采购流程",
      "mermaid": "mermaid/03-purchase-flow.mmd",
      "color": "#5fc79e",
      "summary": "供应商档案(多联系人/多银行·含银行行号) â†’ è¯¢ä»·æ¯”ä»·(产品×供应商横向报价,按产品选中标) â†’ ç¡®è®¤åŽæŒ‰ä¸­æ ‡ä¾›åº”商拆采购订单草稿并走现有 BPM â†’ ç¡®è®¤æ”¶è´§ç”Ÿæˆ MES åˆ°è´§é€šçŸ¥ â†’ IQC(合格/特采入库·退货/报废) â†’ é‡‡è´­å…¥åº“回写订单与申请 â†’ æ¥ç¥¨(AI OCR) â†’ ä»˜æ¬¾å• BPM â†’ ä¾›åº”商往来对账;投标保证金台账独立记录中标与退回。",
      "steps": [
        {
          "stage": "寻源",
          "action": "供应商档案维护多个联系人与多个银行账户(含银行行号);新增询价单时一并录入产品×参与供应商报价并选定每个产品的中标供应商,列表直接确认后按中标供应商生成采购订单草稿;投标保证金台账记录是否中标与退回状态/金额/日期",
          "roles": [
            "采购员",
            "供应商管理员"
          ]
        },
        {
          "stage": "申请",
          "action": "采购申请(CGSQ)提交 BPM,驳回可重置回草稿;通过且有供应商自动生成订单",
          "roles": [
            "采购员",
            "审批人"
          ]
        },
        {
          "stage": "下单",
          "action": "采购订单(CGDD)自动提交 BPM(15→20)",
          "roles": [
            "采购员"
          ]
        },
        {
          "stage": "收货",
          "action": "确认收货生成到货通知(0→待质检2/待入库3→4);需检物料自动创建 IQC",
          "roles": [
            "仓管",
            "IQC è´¨æ£€å‘˜"
          ]
        },
        {
          "stage": "入库",
          "action": "IQC åˆæ ¼/特采→采购入库(待上架→待执行→完成)库存+,回写订单 inCount/inStatus ä¸Žç”³è¯·æ˜Žç»†",
          "roles": [
            "仓管"
          ]
        },
        {
          "stage": "退货",
          "action": "IQC ä¸åˆæ ¼é€€è´§â†’供应商退货出库(选料锁定到订单行);报废走 NCR",
          "roles": [
            "仓管"
          ]
        },
        {
          "stage": "付款",
          "action": "采购来票(FPD, AI OCR, â‰¤è®¢å•总价) â†’ ä»˜æ¬¾å•(FKD å…³è”来票+附件, â‰¤æ¥ç¥¨é‡‘额) BPM å®¡æ‰¹",
          "roles": [
            "财务",
            "审批人"
          ]
        },
        {
          "stage": "对账",
          "action": "供应商往来按供应商汇总:左侧采购订单流水含已开票/未开票金额,右侧来票流水含开票金额/已付/剩余应付并附付款流水,顶部汇总累计采购/开票/付款/未付余额",
          "roles": [
            "财务",
            "采购员"
          ]
        }
      ]
    },
    {
      "id": "production",
      "name": "生产流程",
      "mermaid": "mermaid/04-production-flow.mmd",
      "color": "#e0b34f",
      "summary": "销售订单驱动 MPS â†’ ä¸‹å‘工单 â†’ æŒ‰å·¥è‰ºè·¯çº¿æ‹†å·¥åºä»»åŠ¡ â†’ é¢†æ–™/线边备料 â†’ æŠ¥å·¥(工序顺序硬校验+BPM) â†’ å€’冲消耗/产出入库/IPQC â†’ æœ«é“工序回写进度 â†’ è‡ªåŠ¨å®Œå·¥ â†’ ProductionFinishedEvent å›žå†™é”€å”®è®¢å•。",
      "steps": [
        {
          "stage": "计划",
          "action": "MPS åˆ›å»º(手工或销售订单自动)→下发或合并下发为工单",
          "roles": [
            "计划员"
          ]
        },
        {
          "stage": "派工",
          "action": "工单确认按工艺路线逐工序生成任务,可指派/暂停/取消",
          "roles": [
            "车间主任"
          ]
        },
        {
          "stage": "备料",
          "action": "生产领料单出库或线边虚拟库备料",
          "roles": [
            "仓管"
          ]
        },
        {
          "stage": "报工",
          "action": "提交报工:上一道工序未报满直接拦截;启用 BPM èµ°å®¡æ‰¹",
          "roles": [
            "操作工"
          ]
        },
        {
          "stage": "联动",
          "action": "审批通过按工序标记: å€’冲→自动物料消耗扣线边库(允许负库存);质检→报工转待检验并生成 PENDING äº§å‡ºè¡Œï¼Œç­‰å¾…质检员独立录入 IPQC;关键非检→直接产出入库",
          "roles": [
            "系统自动"
          ]
        },
        {
          "stage": "检验",
          "action": "已完成 IPQC åœ¨è¯¦æƒ…页按物料+盘号+数量一致选择待检产出行关联:回写报工完成并拆分合格/不合格产出入库;未关联的已完成检验单不回写",
          "roles": [
            "IPQC è´¨æ£€å‘˜"
          ]
        },
        {
          "stage": "完工",
          "action": "仅末道工序回写工单已生产数量,达标自动完成;MPS ç´¯è®¡åˆ¤æ–­å®Œæˆå¹¶å‘事件给销售订单",
          "roles": [
            "系统自动"
          ]
        }
      ]
    },
    {
      "id": "quality",
      "name": "质量流程",
      "mermaid": "mermaid/05-quality-flow.mmd",
      "color": "#e06c6c",
      "summary": "四道检验闸口(IQC/IPQC/OQC/RQC,二态 0→4) + å®Œæˆæ—¶æŒ‰åˆæ ¼/不合格数量自动判定(1 æ ¡éªŒé€šè¿‡/2 æ ¡éªŒä¸é€šè¿‡) + NCR è¯„审处置关闭;IPQC å®ŒæˆåŽæŒ‰ç‰©æ–™+盘号+数量关联报工待检行,OQC å®ŒæˆåŽæŒ‰åŒæ ·è§„则关联销售出库待检行;AI æä¾›åˆ¤å®šå»ºè®®ï¼›NCR å½“前仅台账不触发返工单;检验单可由智能质检报告平台一键出件(generate-from-qc:数据从 MES å–,五道校验后渲染落库)。",
      "steps": [
        {
          "stage": "来料",
          "action": "到货通知/外协入库→IQC,结论回写推进待入库或触发退货",
          "roles": [
            "IQC è´¨æ£€å‘˜"
          ]
        },
        {
          "stage": "过程",
          "action": "报工质检工序产出行待检→质检员独立录入 IPQC;完成后按物料+盘号+数量一致关联报工待检行,合格拆行入库、不合格生成 NCR",
          "roles": [
            "IPQC è´¨æ£€å‘˜"
          ]
        },
        {
          "stage": "出货",
          "action": "销售出库提交后行转待检测→质检员独立录入 OQC;完成后按物料+盘号+数量一致关联出库待检行,全部合格后出库单进入待拣货",
          "roles": [
            "OQC è´¨æ£€å‘˜"
          ]
        },
        {
          "stage": "退货",
          "action": "退料/销售退货/售后退货→RQC,结论回调单据与售后",
          "roles": [
            "RQC è´¨æ£€å‘˜"
          ]
        },
        {
          "stage": "导入",
          "action": "IQC/IPQC/OQC ä¸‰åˆ—表各支持 Excel æ‰¹é‡å¯¼å…¥(按钮在「新增」与「导出」之间,纯新增不做更新覆盖):一行一张单,编号留空自动生成,质检方案留空按物料+检验类型自动匹配,检测人员留空取当前登录用户;物料/供应商/客户/工单/工位按编码或名称精确匹配(IPQC ç‰©æ–™ç”±å·¥å•推导,模板不填),合格+不合格=检测/接收数量、IPQC å·¥åºŸ+料废+其他废品≤不合格、OQC å®¢æˆ·é¡»åœ¨æœ¬äººæ•°æ®æƒé™å†…;逐行导入部分成功,失败行进 failureList å¸¦è¡Œå·ä¸Žå¯æ‰§è¡ŒåŠ¨ä½œï¼ŒæˆåŠŸå•ä¸€å¾‹å¾…æ£€éªŒ0;同一批数据不可重复导入:文件内重复行与库内已存在同业务键单据(IQC ç‰©æ–™+供应商+供应商批次+来料日期、IPQC ç”Ÿäº§å·¥å•+检验类型+工位+检测日期、OQC ç‰©æ–™+客户+批次号+出货日期)均拒该行,提示含维度、双方实际值与「先删除该单据再重试」动作",
          "roles": [
            "质检员",
            "IQC è´¨æ£€å‘˜",
            "IPQC è´¨æ£€å‘˜",
            "OQC è´¨æ£€å‘˜"
          ]
        },
        {
          "stage": "不合格",
          "action": "NCR: å¾…评审→评审→处置(退货/报废/返工/让步接收/降级)→关闭",
          "roles": [
            "质量工程师"
          ]
        },
        {
          "stage": "出件",
          "action": "两条出件入口:①通用 generate/preview(调用方自带上下文) æ¸²æŸ“ HTML å¹¶å†»ç»“数据快照,编号 QR+日期+4位流水按天独立,preview åªæ¸²æŸ“不落库不消耗编号,regenerate ç”¨å†»ç»“快照重渲且数据与编号不变并作废已归档 PDF,导出 PDF ç”±åŽç«¯è°ƒ Chromium æ‰“印实例已存的 HTML åŽå½’档附件中心;②generate-from-qc ç”±è´¨æ£€å•直接出件(数据经 MesQcReportApi ä»Ž MES å–,前端不拼上下文),出件前五道校验按业务优先级只报第一处:类型非法/单据不存在/未完成检验/未填判定/模板类型不符/无可用检验项,报告头另带单据原判定 qcResult+qcResultText ä¸Žå¼•擎判定并存,响应回 undecidableCount å¾…判定项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),同一张单据可反复出多份",
          "roles": [
            "质检员"
          ]
        },
        {
          "stage": "建模板",
          "action": "AI å¯¼å…¥ï¼šä¸Šä¼ æ—¢æœ‰æ£€éªŒæŠ¥å‘Šæ–‡ä»¶(扫描件/照片、电子版 PDF、Word/Excel) â†’ è¯†åˆ«æˆæ¨¡æ¿è‰ç¨¿(不落库) â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤è°ƒæ•´åŽä¿å­˜ä¸ºç‰ˆæœ¬ï¼›.doc/.xls æ—§æ ¼å¼ä¸æ”¯æŒ",
          "roles": [
            "质检员",
            "质量工程师"
          ]
        }
      ]
    },
    {
      "id": "warehouse",
      "name": "仓储流程",
      "mermaid": "mermaid/06-warehouse-flow.mmd",
      "color": "#7e6bd6",
      "summary": "15 ç±»å•据驱动库存台账(六元组、四量)变动,全部经事务流水留痕;支持调拨在途、盘点调整、批次/SN è¿½æº¯ã€åº“存占用与四级冻结、线边虚拟库负库存;对外 WmsStockApi ä¾› ERP æ”¶å‘。",
      "steps": [
        {
          "stage": "入库",
          "action": "采购入库/生产入库/产出入库/退货入库/外协入库/杂项入库 â†’ IN äº‹åŠ¡ï¼Œæ‰¹æ¬¡æ··æ”¾æ ¡éªŒã€å››çº§å†»ç»“æ ¡éªŒ",
          "roles": [
            "仓管"
          ]
        },
        {
          "stage": "出库",
          "action": "销售出库/领料/消耗/外协发料/供应商退货/杂项出库 â†’ OUT äº‹åŠ¡ï¼ŒcheckFlag æŽ§åˆ¶å¯å¦è´Ÿåº“å­˜",
          "roles": [
            "仓管",
            "系统自动"
          ]
        },
        {
          "stage": "移库",
          "action": "内部/外部调拨成对 MOVE äº‹åŠ¡ï¼Œåœ¨é€”æ•°é‡è·Ÿè¸ª",
          "roles": [
            "仓管"
          ]
        },
        {
          "stage": "盘点",
          "action": "方案→任务→明细行→结果,盘盈 ADJUST_IN、盘亏 ADJUST_OUT,可冻结与盲盘",
          "roles": [
            "盘点员"
          ]
        },
        {
          "stage": "追溯",
          "action": "批次/SN(uuid)/条码(18ç±»)/装箱嵌套支撑正反向追溯",
          "roles": [
            "质量",
            "售后"
          ]
        }
      ]
    },
    {
      "id": "aftersales",
      "name": "售后流程",
      "mermaid": "mermaid/07-after-sales-flow.mmd",
      "color": "#c78bd6",
      "summary": "工单(六态+问题判定) â†’ å®¢æˆ·è¯‰æ±‚/处理结果附件与流转记录留痕 â†’ ç»´ä¿®ä¸‰ç±»åž‹æˆ–由仓储物流发起销售退货(退货单创建即回写工单流转记录) â†’ RQC è´¨æ£€/入库/退款三回调逐级推进 â†’ å·¥å•完结关闭。",
      "steps": [
        {
          "stage": "受理",
          "action": "人工录入工单(关联客户/订单/合同),填写客户诉求反馈描述并上传附件,创建时指定的处理人直接进入处理中",
          "roles": [
            "客服"
          ]
        },
        {
          "stage": "判定",
          "action": "问题判定: ä¸€èˆ¬/ç»´ä¿®/退货,严重度 0-3",
          "roles": [
            "售后工程师"
          ]
        },
        {
          "stage": "处理",
          "action": "填写处理结果并上传处理结果附件;创建、更新、判定、完结、归档、取消、回访均写入流转记录",
          "roles": [
            "售后工程师"
          ]
        },
        {
          "stage": "ç»´ä¿®",
          "action": "现场/返厂/远程维修,费用与结果记录,完成联动工单完结",
          "roles": [
            "维修工程师"
          ]
        },
        {
          "stage": "退货",
          "action": "售后不再发起退货申请,由仓储物流发起销售退货单(RQC)→质检/入库回调→退款回调→关闭;创建退货单时按销售订单匹配最近一张未关闭/未取消工单,写入「仓储退货」流转记录,草稿或处理中工单推进为待退货",
          "roles": [
            "仓储",
            "质检",
            "财务"
          ]
        }
      ]
    },
    {
      "id": "finance",
      "name": "会计核算流程",
      "mermaid": "mermaid/12-finance-flow.mmd",
      "color": "#3ec9a7",
      "summary": "会计核算:凭证/分录/借贷平衡 -> è¿‡è´¦ -> æ€»è´¦/明细账/报表;固定资产折旧、无形资产摊销。销售采购财务闭环:ERP é”€å”®è®¢å• + MES å·²å®Œæˆå‡ºåº“单 -> CRM å¼€ç¥¨ç”³è¯·ï¼ˆç»‘定订单+出库单)-> å®¡æ‰¹é€šè¿‡ -> å½•入发票并填写合同款/预付款/到货款/验收款/质保金收款节点 -> CRM å›žæ¬¾æŒ‰æ ¸é”€æ˜Žç»†åˆ†æ‘Šåˆ°è®¢å•/出库/开票 -> å®¢æˆ·åº”收对账、已发货未开票、未核销应收账龄、下月回款计划;来票必须同时绑定采购订单与 MES é‡‡è´­å…¥åº“单;凭证可关联来源单据(开票/回款/来票/付款)。",
      "steps": [
        {
          "stage": "建账",
          "action": "维护会计科目 tree(subjectCode å”¯ä¸€ï¼Œå¯å»ºä¸‹çº§),首次可导入约 68 æ¡ä¸€çº§ç§‘目种子",
          "roles": [
            "会计"
          ]
        },
        {
          "stage": "è®°è´¦",
          "action": "录入凭证(字号留空自动生成)+分录,分录逐行选科目并填借/贷;校验至少一行、借贷都不得同时大于0、合计相等且大于0",
          "roles": [
            "会计"
          ]
        },
        {
          "stage": "过账",
          "action": "未过账0 â†’ post è¿‡è´¦ â†’ å·²è¿‡è´¦10(不可再修改);未过账0 æˆ– å·²è¿‡è´¦10 å‡å¯ cancel ä½œåºŸ â†’ å·²ä½œåºŸ20",
          "roles": [
            "会计",
            "财务主管"
          ]
        },
        {
          "stage": "查账",
          "action": "总账按期间逐月汇总、明细账逐条分录并结转余额,两者只统计已过账10的凭证,并支持导出",
          "roles": [
            "会计",
            "财务主管"
          ]
        },
        {
          "stage": "资产",
          "action": "固定资产(1在用/2闲置/3维修中/4已报废)与无形资产(1在用/2已失效/3已摊销完)计提:传 ids æŒ‰é€‰ä¸­è®¡æï¼Œids ä¸ºç©ºå¯¹å…¨éƒ¨æœªæŠ¥åºŸ/未失效资产计提一个月",
          "roles": [
            "资产会计"
          ]
        },
        {
          "stage": "报表",
          "action": "看板汇总资产与凭证时点数 + ç» ErpFinanceStatisticsApi å– ERP å·²å®¡æ ¸æ”¶è´§ä»˜æ¬¾ä¸Žè´­é”€é‡‘额,输出月度收支趋势",
          "roles": [
            "财务主管",
            "管理层"
          ]
        },
        {
          "stage": "来票",
          "action": "选择采购订单 -> é€‰æ‹©è¯¥è®¢å•下已完成 MES é‡‡è´­å…¥åº“单 -> ä¿å­˜æ¥ç¥¨ï¼ˆitem_receipt_id/item_receipt_code)",
          "roles": [
            "采购",
            "财务"
          ]
        },
        {
          "stage": "开票申请",
          "action": "选择合同、销售订单、该订单下已完成出库单,填写开票金额后提交 BPM å®¡æ‰¹",
          "roles": [
            "销售",
            "财务"
          ]
        },
        {
          "stage": "录入发票",
          "action": "审批通过后录入发票号码、日期、附件,并按销售订单填写合同款/预付款/到货款/验收款/质保金及各自时间节点",
          "roles": [
            "财务"
          ]
        },
        {
          "stage": "回款核销",
          "action": "回款单通过 items æŠŠé‡‘额核销到销售订单/销售出库/开票,明细合计必须等于回款金额;审核通过回写回款计划实际金额与状态",
          "roles": [
            "财务"
          ]
        },
        {
          "stage": "对账报表",
          "action": "按客户汇总订单、出库、开票、回款,输出未核销应收、已发货未开票、账龄、下月回款计划与月度趋势",
          "roles": [
            "财务",
            "销售"
          ]
        },
        {
          "stage": "入账",
          "action": "记账凭证填写 sourceType/sourceId/sourceNo å…³è”开票、回款、来票、付款等来源单据",
          "roles": [
            "财务"
          ]
        }
      ]
    },
    {
      "id": "ai",
      "name": "AI ä¸šåŠ¡æµç¨‹",
      "mermaid": "mermaid/11-ai-business-flow.mmd",
      "color": "#56c8d8",
      "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。智能体物料导入为规则+Java é¢„检链路,经人工确认后幂等提交。",
      "steps": [
        {
          "stage": "录入提效",
          "action": "发票 OCR(图片多模态/PDF文本)、报价单 OCR â†’ ç»“构化预填",
          "roles": [
            "采购员",
            "销售"
          ]
        },
        {
          "stage": "主数据导入",
          "action": "上传物料 CSV/XLSX â†’ Python è§£æžå€™é€‰ â†’ Java åªè¯»é¢„检分类/单位/重复编码 â†’ äººå·¥ä¿®è®¢ç¡®è®¤ â†’ å¹‚等提交 â†’ é€è¡Œå›žæ‰§ï¼›åªæ–°å»ºã€ä¸æ›´æ–°ã€ä¸åŒæ­¥ MES",
          "roles": [
            "物料管理员"
          ]
        },
        {
          "stage": "辅助判定",
          "action": "质检单+指标+缺陷 â†’ å»ºè®®åˆæ ¼/特采/退货/报废+理由",
          "roles": [
            "质检员"
          ]
        },
        {
          "stage": "决策预测",
          "action": "缺料/生产时长/生产风险/交付四类预测供计划排产参考",
          "roles": [
            "计划员"
          ]
        },
        {
          "stage": "模板识别",
          "action": "上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) â†’ åˆ†é€šé“抽取(有文本层走文本、扫描版逐页转图) â†’ å¤§æ¨¡åž‹æŒ‰å‰ç«¯ä¸Šä¼ çš„组件清单产出扁平组件草稿 â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤åŽä¿å­˜ä¸ºæ¨¡æ¿ç‰ˆæœ¬",
          "roles": [
            "质检员",
            "质量工程师"
          ]
        },
        {
          "stage": "知识检索",
          "action": "文档→切片→Milvus å‘量→检索接口(预留)",
          "roles": [
            "—"
          ]
        }
      ]
    }
  ]
}
}
docs/project-business/data/modules.json
@@ -11,8 +11,8 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票申请(绑定销售订单+MES销售出库单)→审批→录入发票(发票号/日期/附件+合同款/预付款/到货款/验收款/质保金收款节点)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验·核销明细可拆分到订单/出库/开票)", "回款计划(收款节点回写已回款/实际时间/状态)", "客户应收对账/已发货未开票/未核销应收账龄/月度开票回款趋势", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "回款核销明细", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
     "coreTables": ["crm_clue", "crm_customer", "crm_contact", "crm_business", "crm_sale_quotation", "crm_contract", "crm_invoice", "crm_receivable", "crm_receivable_plan", "crm_permission"]},
@@ -27,18 +27,18 @@
    {"key": "erp", "name": "进销存与财务", "color": "#5fc79e", "tag": "ERP", "flow": "purchase",
     "positioning": "商贸与资金单据中枢,向 MES å‘起实物收发",
     "problem": "采购销售单据审批、来票付款控制、订单执行进度回写",
     "features": ["采购申请(BPM)→订单(自动/手动生成)", "确认收货→MES到货通知", "入库/退货数量状态回写", "采购来票(金额≤订单)", "付款单(必须关联来票·BPM)", "供应商往来(订单/来票/付款三流水·左右展示开票金额)", "销售订单(BPM·审批通过自动建MPS)", "出库状态回写", "收款单(关联CRM客户)", "结算账户", "AI发票OCR"],
     "features": ["采购申请(BPM)→订单(自动/手动生成)", "确认收货→MES到货通知", "入库/退货数量状态回写", "采购来票(绑定采购订单+MES采购入库单·双层剩余额度校验)", "付款单(必须关联来票·BPM)", "供应商往来(订单/来票/付款三流水·左右展示开票金额)", "销售订单(BPM·审批通过自动建MPS)", "出库状态回写", "收款单(关联CRM客户)", "结算账户", "AI发票OCR"],
     "objects": ["采购申请", "采购订单", "采购来票", "付款单", "供应商往来", "销售订单", "收款单", "结算账户"],
     "roles": ["采购员", "销售员", "出纳", "会计"],
     "upstream": ["CRM(合同)", "SRM(询价确认)", "主数据"], "downstream": ["生产(MPS)", "仓储(收发)"],
     "upstream": ["CRM(合同)", "SRM(询价确认)", "主数据"], "downstream": ["生产(MPS)", "仓储(收发)", "财务(来票付款入账)"],
     "coreTables": ["erp_purchase_request", "erp_purchase_order", "erp_purchase_invoice", "erp_finance_payment", "erp_sale_order", "erp_finance_receipt", "erp_account"]},
    {"key": "finance", "name": "财务管理(会计核算)", "color": "#3ec9a7", "tag": "FIN", "flow": "finance",
     "positioning": "独立核算模块:会计核算 + èµ„产核算 + è´¢åŠ¡æŠ¥è¡¨çœ‹æ¿ï¼ˆä¸Ž ERP è¿›é”€å­˜è´¢åŠ¡å•æ®åˆ†ç¦»ï¼Œyudao-module-finance)",
     "problem": "进销存只管单据不管账,缺少会计科目/凭证/账簿与资产折旧摊销沉淀,无法出账表与资产净值",
     "features": ["会计科目树(约68条一级科目种子·可建下级)", "记账凭证(字号自动生成·分录借贷平衡前后端双重校验)", "凭证状态机 æœªè¿‡è´¦0→过账10/作废20,已过账不可改", "总账按期间逐月汇总", "明细账逐条分录并结转余额", "固定资产+计提折旧", "无形资产+计提摊销", "会计核算看板(收支/资产/凭证/月度趋势)", "附件走系统附件中心 recordType=fin_voucher"],
     "features": ["会计科目树(约68条一级科目种子·可建下级)", "记账凭证(字号自动生成·分录借贷平衡前后端双重校验)", "凭证状态机 æœªè¿‡è´¦0→过账10/作废20,已过账不可改", "总账按期间逐月汇总", "明细账逐条分录并结转余额", "固定资产+计提折旧", "无形资产+计提摊销", "会计核算看板(收支/资产/凭证/月度趋势)", "凭证来源单据关联(sourceType/sourceId/sourceNo,可回溯开票/回款/来票/付款)", "报表取数接入CRM客户应收对账/已发货未开票/未核销应收账龄/月度开票回款趋势", "附件走系统附件中心 recordType=fin_voucher"],
     "objects": ["会计科目", "记账凭证", "凭证分录", "固定资产", "无形资产"],
     "roles": ["会计", "财务主管", "资产会计", "管理层"],
     "upstream": ["进销存(ERP å·²å®¡æ ¸æ”¶è´§/付款/购销单据)"], "downstream": ["财务报表看板", "BI ç®¡ç†é©¾é©¶èˆ±"],
     "upstream": ["进销存(ERP å·²å®¡æ ¸æ”¶è´§/付款/购销单据)", "CRM(开票/回款/核销/对账报表)"], "downstream": ["财务报表看板", "BI ç®¡ç†é©¾é©¶èˆ±"],
     "coreTables": ["fin_account_subject", "fin_voucher", "fin_voucher_entry", "fin_fixed_asset", "fin_intangible_asset"]},
    {"key": "pro", "name": "生产管理", "color": "#e0b34f", "tag": "MES-PRO", "flow": "production",
     "positioning": "生产执行核心:计划-工单-任务-报工",
@@ -83,15 +83,15 @@
    {"key": "aftersales", "name": "售后服务", "color": "#c78bd6", "tag": "AFTER", "flow": "aftersales",
     "positioning": "客户反馈到退货/ç»´ä¿®/退款闭环",
     "problem": "售后工单流转、退货与质检入库退款的跨系统联动",
     "features": ["工单六态(草稿→处理中→待入库→待退款→完结→关闭)", "问题判定(一般/ç»´ä¿®/退货)", "ç»´ä¿®(现场/返厂/远程)", "退货申请→自动建 MES é”€å”®é€€è´§å•", "质检结论/入库/退款三重回调", "行级数据权限四视图"],
     "objects": ["售后工单", "维修记录", "退货申请"],
     "features": ["工单六态(草稿→处理中→待入库→待退款→完结→关闭)", "问题判定(一般/ç»´ä¿®/退货)", "客户诉求反馈描述+附件", "处理结果+附件", "工单流转记录", "ç»´ä¿®(现场/返厂/远程)", "退货由仓储物流发起→MES é”€å”®é€€è´§å•(原售后退货申请页面已下线)", "质检结论/入库/退款三重回调", "行级数据权限四视图"],
     "objects": ["售后工单", "工单流转记录", "维修记录", "退货申请(后台保留)"],
     "roles": ["客服", "维修工程师", "售后管理员"],
     "upstream": ["CRM(客户)", "ERP(销售订单)"], "downstream": ["仓储(销售退货)", "质量(RQC)", "财务(退款)"],
     "coreTables": ["after_sale_ticket", "after_sale_ticket_item", "after_sale_repair", "after_sale_return", "after_sale_permission"]},
     "coreTables": ["after_sale_ticket", "after_sale_ticket_item", "after_sale_ticket_flow", "after_sale_repair", "after_sale_return", "after_sale_permission"]},
    {"key": "hrm", "name": "人力资源", "color": "#9db85a", "tag": "HRM", "flow": null,
     "positioning": "员工入转调离与考勤薪酬",
     "problem": "人事流程审批化、离职数据不丢(交接)、算薪自动化",
     "features": ["员工档案(入职自动建系统账号)", "请假/加班/调岗/离职 BPM å®¡æ‰¹", "离职交接映射 expandUserIds", "考勤(移动端我的考勤状态·打卡·规则·排班·补卡审批·月度汇总)", "薪酬(应发-社保公积金-个税-请假扣款·1.5/2/3倍加班费·发放台账)", "培训证书(SQL先行待实现)"],
     "features": ["员工档案(入职自动建系统账号)", "请假/加班/调岗/离职 BPM å®¡æ‰¹", "离职交接同时转移员工ID/用户ID/业务数据 expandUserIds", "离职到期自动把员工状态改为离职并停用系统账号", "考勤(移动端我的考勤状态·打卡·规则·排班·补卡审批·月度汇总)", "薪酬(应发-社保公积金-个税-请假扣款·1.5/2/3倍加班费·发放台账)", "培训证书(SQL先行待实现)"],
     "objects": ["员工", "请假单", "离职申请", "交接映射", "考勤记录", "薪酬核算"],
     "roles": ["HR", "部门主管", "员工本人"],
     "upstream": [], "downstream": ["各模块(数据可见性扩展)", "system(账号)"],
docs/project-business/data/objects.json
@@ -8,20 +8,21 @@
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB é…ç½®å¼é˜¶æ®µæŽ¨è¿›ï¼Œç»“束态: èµ¢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business èµ¢å• è¾“单 é˜¶æ®µ"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM ç‰©æ–™æŠ¥ä»·ï¼ŒAI OCR å½•入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM å®¡æ‰¹(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单;支持 Excel å¯¼å…¥ï¼ˆä¸€åˆåŒå¤šäº§å“æ˜Žç»†è¡Œã€åˆå¹¶å•元格自动拉平,客户与物料按名称/编码校验);产品明细带出物料名称/编码/条码/规格型号/计量单位;删除合同时连带删除产品明细,不留孤儿数据", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract å®¡æ‰¹ å¯¼å…¥"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;支持 Excel å¯¼å…¥ï¼ˆä»…新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验", "upstream": ["合同", "发票"], "downstream": ["收款单(关联)"], "keywords": "回款 æ”¶æ¬¾è®¡åˆ’ receivable"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;开票申请须绑定 ERP é”€å”®è®¢å•与 MES é”€å”®å‡ºåº“单,审批通过后进入录入发票阶段(entry_status 0待录入→10已录入),录入时填写发票号码/日期/附件并设置合同款/预付款/到货款/验收款/质保金收款节点;支持 Excel å¯¼å…¥ï¼ˆä»…新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同", "销售订单", "销售出库单"], "downstream": ["回款", "回款计划", "记账凭证"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡ å¼€ç¥¨ç”³è¯· å½•入发票 å‡ºåº“单"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验;新增/编辑可带核销明细 items(20销售订单/21销售出库/23开票),明细合计必须等于回款金额,审核通过后回写回款计划已回款额/实际回款时间/状态(0未回款10部分回款20已回清)", "upstream": ["合同", "发票", "销售订单", "销售出库单"], "downstream": ["收款单(关联)", "回款核销明细", "回款计划", "记账凭证"], "keywords": "回款 æ”¶æ¬¾è®¡åˆ’ receivable æ ¸é”€"},
    {"name": "回款核销明细", "module": "销售管理", "table": "crm_receivable_item", "doc": "每行把回款金额核销到一张业务单据:biz_type 20销售订单/21销售出库/23开票,biz_no、单据金额、已核销金额由后端按 biz_id å›žå¡«ï¼›æ¯è¡Œæ ¸é”€é¢ä¸å¾—超过该单据剩余应收;不传 items ä¸”带 invoice_id æ—¶è‡ªåŠ¨ç”Ÿæˆä¸€æ¡å¼€ç¥¨æ ¸é”€è¡Œ", "upstream": ["回款", "销售订单", "销售出库单", "发票"], "downstream": ["客户应收对账", "未核销应收报表"], "keywords": "核销 æ ¸é”€æ˜Žç»† write off item receivable_item"},
    {"name": "销售目标", "module": "销售管理", "table": "crm_sales_target", "doc": "å¹´/å­£/月×个人/部门/产品维度的合同回款等目标", "upstream": [], "downstream": ["业绩统计"], "keywords": "目标 target ä¸šç»©"},
    {"name": "供应商", "module": "供应商管理", "table": "srm_supplier", "doc": "供应商档案:执照/税号;多个联系人(srm_supplier_contact)与多个银行账户(srm_supplier_bank·含银行行号),主表保留默认联系人/默认银行字段,ERP ä¸Ž MES å…±ç”¨", "upstream": ["手工建档/Excel å¯¼å…¥"], "downstream": ["询价比价", "投标保证金", "采购订单", "到货通知", "供应商退货", "供应商往来"], "keywords": "供应商 supplier vendor è”系人 å¼€æˆ·è¡Œ é“¶è¡Œè¡Œå·"},
    {"name": "询价比价", "module": "供应商管理", "table": "srm_inquiry", "doc": "XJ编号,草稿0→已转订单20→已取消30(10询价中为历史状态);新增/编辑时一并录入产品行×参与供应商报价(srm_inquiry_quote)并选定每个产品的中标供应商,无需单独的报价/比价步骤,确认后按中标供应商拆分生成采购订单草稿并记录 srm_inquiry_order", "upstream": ["供应商", "主数据(物料)"], "downstream": ["采购订单(草稿)"], "keywords": "询价 æ¯”ä»· æŠ¥ä»· ä¸­æ ‡ inquiry quote"},
    {"name": "投标保证金", "module": "供应商管理", "table": "srm_bid_bond", "doc": "BZ编号台账,关联供应商/询价单/采购订单,记录保证金金额、缴纳日期、是否中标、退回状态(0未退1部分2已退3无需退)/退回金额/退回日期", "upstream": ["供应商", "询价比价", "采购订单"], "downstream": ["供应商往来(参考)"], "keywords": "投标保证金 ä¸­æ ‡ é€€å›ž bid bond"},
    {"name": "采购申请", "module": "进销存", "table": "erp_purchase_request", "doc": "CGSQ编号,BPM å®¡æ‰¹ï¼Œé€šè¿‡åŽæœ‰ä¾›åº”商自动生成订单", "upstream": ["生产缺口(人工)"], "downstream": ["采购订单"], "keywords": "采购申请 è¯·è´­ request"},
    {"name": "采购订单", "module": "进销存", "table": "erp_purchase_order", "doc": "菜单名「采购台账」,CGDD编号,BPM å®¡æ‰¹(草稿0→审批中15→已审核20),入库状态:未0部分1全部2;表头含采购类型(生产/办公用品/其他)、合同号(手工输入不自动生成)、项目名称、付款日期(必填,超30天账期标红)、付款方式(电汇/汇票/现金/手机支付);明细产品编号快照 MDM ç‰©æ–™ç¼–码;来源可为采购申请、询价比价确认生成的草稿或销售订单/生产工单(来源单号回溯);单价为含税单价,价税合计=单价×数量,税额从含税金额中反算(不再额外加税)", "upstream": ["采购申请", "询价比价(确认中标)"], "downstream": ["到货通知", "采购来票", "供应商退货(约束物料)", "供应商往来"], "keywords": "采购订单 purchase"},
    {"name": "采购来票", "module": "进销存", "table": "erp_purchase_invoice", "doc": "FPD编号,AI OCR é¢„填,累计≤订单总价,付款单的前置;供应商往来右侧展示开票金额/已付/剩余应付", "upstream": ["采购订单"], "downstream": ["付款单", "供应商往来"], "keywords": "来票 å‘票 é‡‡è´­å‘票"},
    {"name": "采购来票", "module": "进销存", "table": "erp_purchase_invoice", "doc": "FPD编号,AI OCR é¢„填,累计≤订单总价,付款单的前置;必须同时绑定采购订单与 MES é‡‡è´­å…¥åº“单(item_receipt_id),按订单与入库单两层剩余可来票额度校验;供应商往来右侧展示开票金额/已付/剩余应付", "upstream": ["采购订单", "MES é‡‡è´­å…¥åº“单"], "downstream": ["付款单", "供应商往来", "记账凭证"], "keywords": "来票 å‘票 é‡‡è´­å‘票 å…¥åº“单"},
    {"name": "付款单", "module": "财务", "table": "erp_finance_payment", "doc": "FKD编号,必须关联来票+附件,累计≤来票金额,BPM å®¡æ‰¹", "upstream": ["采购来票"], "downstream": ["结算账户", "供应商往来"], "keywords": "付款 åº”付 payment"},
    {"name": "供应商往来", "module": "进销存", "table": "无独立表(聚合视图)", "doc": "按供应商+时间范围聚合采购订单/来票/付款:汇总累计采购额、累计开票额、累计付款额、未付余额;左侧订单行含已开票/未开票金额,右侧来票行含开票金额/已付/剩余应付", "upstream": ["采购订单", "采购来票", "付款单"], "downstream": [], "keywords": "供应商往来 å¯¹è´¦ å¼€ç¥¨é‡‘额 åº”付 statement"},
    {"name": "收款单", "module": "财务", "table": "erp_finance_receipt", "doc": "SKD编号,关联 CRM å®¢æˆ·æ ¡éªŒï¼Œæ—  BPM(手动审核)", "upstream": ["销售(弱关联)"], "downstream": ["结算账户"], "keywords": "收款 åº”æ”¶ receipt"},
    {"name": "会计科目", "module": "会计核算", "table": "fin_account_subject", "doc": "科目树,parent_id è‡ªå…³è”;科目类型1资产2负债3权益4成本5损益,余额方向1借2贷,状态0启用1禁用;账表与分录统一按 subject_code å–æ•°", "upstream": ["会计科目种子约68条一级科目"], "downstream": ["记账凭证分录", "总账", "明细账"], "keywords": "会计科目 ç§‘ç›® subject account èµ„产类 è´Ÿå€ºç±» æŸç›Šç±»"},
    {"name": "记账凭证", "module": "会计核算", "table": "fin_voucher", "doc": "凭证字号可留空由后端自动生成;状态 æœªè¿‡è´¦0/已过账10/已作废20;已过账不可改,未过账与已过账均可作废,未过账与已作废可删除;附件按 recordType=fin_voucher æŒ‚系统附件中心", "upstream": ["会计科目"], "downstream": ["总账", "明细账", "财务报表看板"], "keywords": "记账凭证 å‡­è¯ voucher è¿‡è´¦ ä½œåºŸ å€Ÿè´·å¹³è¡¡"},
    {"name": "记账凭证", "module": "会计核算", "table": "fin_voucher", "doc": "凭证字号可留空由后端自动生成;状态 æœªè¿‡è´¦0/已过账10/已作废20;已过账不可改,未过账与已过账均可作废,未过账与已作废可删除;可通过 source_type/source_id/source_no å…³è”开票/回款/来票/付款等来源单据并支持按来源单号检索;附件按 recordType=fin_voucher æŒ‚系统附件中心", "upstream": ["会计科目", "开票", "回款", "采购来票", "付款单"], "downstream": ["总账", "明细账", "财务报表看板"], "keywords": "记账凭证 å‡­è¯ voucher è¿‡è´¦ ä½œåºŸ å€Ÿè´·å¹³è¡¡"},
    {"name": "凭证分录", "module": "会计核算", "table": "fin_voucher_entry", "doc": "凭证下逐行分录:科目编码+借/贷金额+辅助核算(1客户2供应商3部门4员工5项目);一行不可借贷同时大于0,全表借贷合计必须相等且大于0", "upstream": ["记账凭证", "会计科目"], "downstream": ["总账", "明细账"], "keywords": "凭证分录 åˆ†å½• entry å€Ÿæ–¹ è´·æ–¹ è¾…助核算"},
    {"name": "固定资产", "module": "会计核算", "table": "fin_fixed_asset", "doc": "资产卡片,状态 1在用/2闲置/3维修中/4已报废;月折旧额=(原值×(1-残值率))÷(年限×12),累计折旧与净值由计提累加,不接收前端传值", "upstream": [], "downstream": ["财务报表看板", "总账"], "keywords": "固定资产 æŠ˜æ—§ èµ„产卡片 fixed asset æ®‹å€¼çއ"},
    {"name": "无形资产", "module": "会计核算", "table": "fin_intangible_asset", "doc": "无形资产卡片(证书编号/有效期),状态 1在用/2已失效/3已摊销完;月摊销额=(原值×(1-残值率))÷(摊销年限×12),累计摊销与净值由计提累加", "upstream": [], "downstream": ["财务报表看板", "总账"], "keywords": "无形资产 æ‘Šé”€ è½¯ä»¶è‘—作权 ä¸“利 intangible æœ‰æ•ˆæœŸ"},
@@ -31,11 +32,11 @@
    {"name": "生产任务", "module": "生产", "table": "mes_pro_task", "doc": "工序粒度,预备0→生产中1→部分2→完成4,可暂停9取消5", "upstream": ["生产工单"], "downstream": ["报工"], "keywords": "任务 task å·¥åº"},
    {"name": "报工单", "module": "生产", "table": "mes_pro_feedback", "doc": "草稿0→审批中2→待检3/完成4,按工序顺序校验(上道工序报满才可报),联动倒冲消耗/产出入库/IPQC;质检工序报工审批后转待检验并生成 PENDING äº§å‡ºè¡Œï¼Œç­‰å·²å®Œæˆ IPQC æŒ‰ç‰©æ–™+盘号+数量关联后才回写完成", "upstream": ["生产任务"], "downstream": ["产品产出", "物料消耗", "IPQC"], "keywords": "报工 feedback"},
    {"name": "工艺路线", "module": "生产", "table": "mes_pro_route", "doc": "工序顺序+质检/关键/倒冲标记,驱动工单任务与报工规则", "upstream": [], "downstream": ["生产工单"], "keywords": "工艺 è·¯çº¿ route å·¥åº"},
    {"name": "到货通知单", "module": "仓储", "table": "mes_wm_arrival_notice", "doc": "采购订单确认收货生成,草稿0→待质检2→待入库3→完成4", "upstream": ["采购订单"], "downstream": ["IQC", "采购入库"], "keywords": "到货 æ”¶è´§ arrival"},
    {"name": "到货通知单", "module": "仓储", "table": "mes_wm_arrival_notice", "doc": "采购订单确认收货生成,草稿0→待质检2→待入库3→完成4;表头带采购合同号(选择采购订单自动带入,换货到货沿用原退货单合同号),可按合同号检索", "upstream": ["采购订单", "供应商退货(换货)"], "downstream": ["IQC", "采购入库"], "keywords": "到货 æ”¶è´§ arrival åˆåŒå·"},
    {"name": "采购入库单", "module": "仓储", "table": "mes_wm_item_receipt", "doc": "草稿0→待上架2→待执行3→完成4,完成后库存+并回写 ERP inCount/inStatus", "upstream": ["到货通知"], "downstream": ["库存台账", "采购订单(回写)"], "keywords": "采购入库 æ”¶è´§å…¥åº“ receipt"},
    {"name": "生产领料单", "module": "仓储", "table": "mes_wm_product_issue", "doc": "草稿0→待拣货2→待执行3→完成4,库存-", "upstream": ["生产工单"], "downstream": ["库存台账"], "keywords": "领料 issue"},
    {"name": "销售出库单", "module": "仓储", "table": "mes_wm_product_sales", "doc": "发货通知审核后生成,提交后有 oqcCheckFlag è¡Œè½¬å¾…检测1(提交时不再自动建 OQC),需质检员独立录入 OQC å¹¶åœ¨æ£€éªŒå•详情按物料+盘号+数量关联待检行;全部待检行合格后转待拣货2→发运10→待执行3→完成4,回写销售订单出库数与状态", "upstream": ["发货通知", "销售订单"], "downstream": ["库存台账", "OQC"], "keywords": "销售出库 å‘è´§ å‡ºåº“单 sales"},
    {"name": "销售退货单", "module": "仓储", "table": "mes_wm_return_sales", "doc": "草稿→待检验(RQC)→待上架→待入库→完成,售后与手工两来源", "upstream": ["售后退货申请"], "downstream": ["库存台账", "RQC"], "keywords": "销售退货 é€€è´§å…¥åº“"},
    {"name": "销售退货单", "module": "仓储", "table": "mes_wm_return_sales", "doc": "草稿→待检验(RQC)→待上架→待入库→完成,售后与手工两来源", "upstream": ["售后退货(仓储发起)"], "downstream": ["库存台账", "RQC"], "keywords": "销售退货 é€€è´§å…¥åº“"},
    {"name": "供应商退货单", "module": "仓储", "table": "mes_wm_return_vendor", "doc": "质量部发起退货,草稿0→审批中1(BPM:采购→质量→仓库)→待拣货2→待执行退货3→完成4,可取消5,库存-;上传附件走 storage-attachment;处理方式 1退款(完成后自动生成负数冲红付款单) / 2换货(完成后自动生成待质检到货通知单并创建 IQC,重新入库质检);合同号从采购订单快照", "upstream": ["IQC", "采购订单(合同号)"], "downstream": ["库存台账", "采购订单(returnCount)", "付款单(冲红)", "到货通知(换货)"], "keywords": "供应商退货 é€€æ–™ é€€è´§ vendor"},
    {"name": "调拨单", "module": "仓储", "table": "mes_wm_transfer", "doc": "内部/外部调拨,成对 MOVE_OUT+MOVE_IN äº‹åŠ¡ï¼Œåœ¨é€”æ•°é‡", "upstream": [], "downstream": ["库存台账"], "keywords": "调拨 ç§»åº“ transfer"},
    {"name": "盘点任务", "module": "仓储", "table": "mes_wm_stock_taking_task", "doc": "静态/动态盘点,行结果:正常/盘盈(ADJUST_IN)/盘亏(ADJUST_OUT),可冻结库存、盲盘", "upstream": ["库存台账"], "downstream": ["库存台账(调整)"], "keywords": "盘点 ç›˜ç›ˆ ç›˜äº stock taking"},
@@ -47,10 +48,12 @@
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type å­—典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC ä¸‰è€…都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC é€€è´§æ£€éªŒ é€€æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"},
    {"name": "设备", "module": "设备", "table": "mes_dv_machinery", "doc": "台账状态:停机/生产中/保养中;点检·保养·维修工单;AI é£Žé™©é¢„测消费其数据", "upstream": [], "downstream": ["点检", "ç»´ä¿®", "生产风险预测"], "keywords": "设备 ç‚¹æ£€ ä¿å…» ç»´ä¿® machinery"},
    {"name": "售后工单", "module": "售后", "table": "after_sale_ticket", "doc": "草稿0→处理中10→待入库30→待退款40→完结50→关闭60/取消99,判定:一般/ç»´ä¿®/退货", "upstream": ["客户反馈(人工录入)"], "downstream": ["维修记录", "退货申请"], "keywords": "售后 å·¥å• ticket ç»´ä¿®"},
    {"name": "退货申请", "module": "售后", "table": "after_sale_return", "doc": "草稿0→已审核20(自动建MES退货单)→已质检40→已入库50→已退款60→关闭70;类型:退款退货/换货/仅退款", "upstream": ["售后工单"], "downstream": ["销售退货单", "退款"], "keywords": "售后退货 return"},
    {"name": "员工", "module": "人力", "table": "hrm_employee", "doc": "在职1/试用2/离职3,入职自动建系统账号", "upstream": [], "downstream": ["交接映射", "考勤", "薪酬"], "keywords": "员工 å…¥èŒ employee"},
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "from→to æ˜ å°„,已完成20 åŽ expandUserIds æ‰©å±•交接人可见数据(当前各业务模块尚未实际接入调用)", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询"], "keywords": "交接 ç¦»èŒ handover expandUserIds"},
    {"name": "售后工单", "module": "售后", "table": "after_sale_ticket", "doc": "草稿0→处理中10→待入库30→待退款40→完结50→关闭60/取消99,判定:一般/ç»´ä¿®/退货;客户诉求反馈描述+附件、处理结果+附件;创建/更新/判定/完结/归档/取消/回访写入流转记录", "upstream": ["客户反馈(人工录入)"], "downstream": ["维修记录", "仓储销售退货(原退货申请页面已下线)"], "keywords": "售后 å·¥å• ticket ç»´ä¿® å®¢æˆ·è¯‰æ±‚ å¤„理结果 æµè½¬è®°å½•"},
    {"name": "工单流转记录", "module": "售后", "table": "after_sale_ticket_flow", "doc": "售后工单每次动作留痕:动作编码/名称、变更前后状态、内容、操作人、时间", "upstream": ["售后工单"], "downstream": [], "keywords": "售后 æµè½¬è®°å½• flow"},
    {"name": "退货申请", "module": "售后", "table": "after_sale_return", "doc": "后台保留,售后页面已下线;草稿0→已审核20(自动建MES退货单)→已质检40→已入库50→已退款60→关闭70;类型:退款退货/换货/仅退款", "upstream": ["售后工单"], "downstream": ["销售退货单", "退款"], "keywords": "售后退货 return"},
    {"name": "员工", "module": "人力", "table": "hrm_employee", "doc": "在职1/试用2/离职3,入职自动建系统账号;离职申请到期自动置为离职3并停用系统账号", "upstream": [], "downstream": ["交接映射", "考勤", "薪酬"], "keywords": "员工 å…¥èŒ employee ç¦»èŒ"},
    {"name": "离职申请", "module": "人力", "table": "hrm_resignation_application", "doc": "草稿0→审批中10→通过20/驳回30/撤销40;审批通过后可交接0→10→20;到期任务写实际离职日期、把员工状态改为离职3并停用系统账号", "upstream": ["员工"], "downstream": ["员工档案状态", "离职交接"], "keywords": "离职 resignation å®¡æ‰¹ äº¤æŽ¥ è‡ªåŠ¨ç¦»èŒ"},
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "同时记录离职/交接双方员工ID与用户ID;交接完成20 åŽ expandUserIds æ‰©å±•交接人可见数据;审批通过且到预计离职日期后自动把员工状态改为离职3并停用系统账号", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询", "员工档案状态"], "keywords": "交接 ç¦»èŒ handover expandUserIds è‡ªåŠ¨ç¦»èŒ"},
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"},
    {"name": "报告模板", "module": "质检报告", "table": "qc_report_template", "doc": "智能质检报告模板,启用0/停用1;画布内容存在 qc_report_template_version(草稿0/已发布1/已停用2,已发布不可覆盖),currentVersion æŒ‡å‘当前已发布版本;源文件经 AI å¯¼å…¥è¯†åˆ«æˆè‰ç¨¿åŽç”±äººå·¥ç¡®è®¤æ‰æˆä¸ºç‰ˆæœ¬ï¼›åŒä¸€æ¨¡æ¿é‡å¤å¯¼å…¥æŒ‰ã€æ›¿æ¢ã€‘处理——先删旧附件(连带 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ï¼‰å†ç»‘新的,不留孤儿文件;删除模板会连带清附件", "upstream": ["人工设计(GrapesJS è®¾è®¡å™¨)", "AI å¯¼å…¥è‰ç¨¿(qc-report/ai-import/draft)"], "downstream": ["模板版本", "报告实例"], "keywords": "报告模板 è´¨æ£€æŠ¥å‘Š template è®¾è®¡å™¨ ç‰ˆæœ¬ AI导入 è¯†åˆ« è‰ç¨¿ æºæ–‡ä»¶æ›¿æ¢"},
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"}
docs/project-business/data/roles.json
@@ -16,7 +16,7 @@
    {"role": "仓管员", "modules": ["MES-WM"], "operations": ["15 ç±»å‡ºå…¥åº“单据执行", "调拨", "盘点", "条码装箱"], "data": ["mes_wm_*"], "scope": "仓库维度;WMS å¯æŒ‰ä»“库配置出入库/调拨/盘点是否走 BPM"},
    {"role": "质检员", "modules": ["MES-QC"], "operations": ["IQC/IPQC/OQC/RQC æ£€éªŒ", "NCR è¯„审处置"], "data": ["mes_qc_*"], "scope": "inspectorUserId å½’属"},
    {"role": "设备员/维修工", "modules": ["MES-DV"], "operations": ["点检保养", "维修工单", "工装领用"], "data": ["mes_dv_*", "mes_tm_*"], "scope": "chargeUserId å½’属"},
    {"role": "客服/售后工程师", "modules": ["售后"], "operations": ["工单受理派工", "ç»´ä¿®", "退货申请"], "data": ["after_sale_*"], "scope": "行级数据权限四视图:我负责/我参与/下属负责/全部(aftersales:view-all)"},
    {"role": "客服/售后工程师", "modules": ["售后"], "operations": ["工单受理派工", "客户诉求/处理结果附件", "ç»´ä¿®", "退货转仓储发起"], "data": ["after_sale_*"], "scope": "行级数据权限四视图:我负责/我参与/下属负责/全部(aftersales:view-all)"},
    {"role": "财务", "modules": ["ERP", "CRM", "FIN"], "operations": ["收款单", "付款单", "开票审核", "结算账户", "会计科目维护", "记账凭证录入与过账/作废", "总账与明细账查询", "固定资产折旧计提", "无形资产摊销计提", "会计核算看板"], "data": ["erp_finance_*", "crm_invoice", "crm_receivable", "fin_account_subject", "fin_voucher", "fin_voucher_entry", "fin_fixed_asset", "fin_intangible_asset"], "scope": "financeUserId å½’属;财务核算模块按 finance:* æŽ¥å£æƒé™ + system_menu æŽˆæƒï¼Œæš‚无数据权限字段"},
    {"role": "HR", "modules": ["HRM"], "operations": ["员工档案入转调离", "考勤规则排班", "薪酬核算发放", "离职交接"], "data": ["hrm_*"], "scope": "部门范围 dataScope"},
    {"role": "管理层", "modules": ["BI"], "operations": ["查看采购销售/仓库/生产设备/质量/人力五大主题大屏"], "data": ["bi_dashboard"], "scope": "只读"}
docs/project-business/index.html
@@ -241,7 +241,7 @@
    ],
    [
      "售后如何闭环",
      "工单判定退货 â†’ é€€è´§ç”³è¯·å®¡æ ¸ â†’ è‡ªåŠ¨å»º MES é”€å”®é€€è´§å• â†’ RQC è´¨æ£€/入库回调 â†’ é€€æ¬¾å›žè°ƒ â†’ å·¥å•完结关闭"
      "工单判定退货 â†’ ä»“储物流发起销售退货(原售后退货申请页面已下线) â†’ å»º MES é”€å”®é€€è´§å• â†’ RQC è´¨æ£€/入库回调 â†’ é€€æ¬¾å›žè°ƒ â†’ å·¥å•完结关闭"
    ],
    [
      "AI åœ¨å“ªé‡Œå‚与",
@@ -344,8 +344,8 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单·支持Excel导入)", "开票申请(绑定销售订单+MES销售出库单)→审批→录入发票(发票号/日期/附件+合同款/预付款/到货款/验收款/质保金收款节点)", "开票(BPM审批·支持Excel导入·同一批数据不可重复导入)→回款(先开票校验·核销明细可拆分到订单/出库/开票)", "回款计划(收款节点回写已回款/实际时间/状态)", "客户应收对账/已发货未开票/未核销应收账龄/月度开票回款趋势", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "回款核销明细", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
     "coreTables": ["crm_clue", "crm_customer", "crm_contact", "crm_business", "crm_sale_quotation", "crm_contract", "crm_invoice", "crm_receivable", "crm_receivable_plan", "crm_permission"]},
@@ -360,7 +360,7 @@
    {"key": "erp", "name": "进销存与财务", "color": "#5fc79e", "tag": "ERP", "flow": "purchase",
     "positioning": "商贸与资金单据中枢,向 MES å‘起实物收发",
     "problem": "采购销售单据审批、来票付款控制、订单执行进度回写",
     "features": ["采购申请(BPM)→订单(自动/手动生成)", "确认收货→MES到货通知", "入库/退货数量状态回写", "采购来票(金额≤订单)", "付款单(必须关联来票·BPM)", "销售订单(BPM·审批通过自动建MPS)", "出库状态回写", "收款单(关联CRM客户)", "结算账户", "AI发票OCR"],
     "features": ["采购申请(BPM)→订单(自动/手动生成)", "确认收货→MES到货通知", "入库/退货数量状态回写", "采购来票(绑定采购订单+MES采购入库单·双层剩余额度校验)", "付款单(必须关联来票·BPM)", "销售订单(BPM·审批通过自动建MPS)", "出库状态回写", "收款单(关联CRM客户)", "结算账户", "AI发票OCR"],
     "objects": ["采购申请", "采购订单", "采购来票", "付款单", "销售订单", "收款单", "结算账户"],
     "roles": ["采购员", "销售员", "出纳", "会计"],
     "upstream": ["CRM(合同)", "SRM(定标)", "主数据"], "downstream": ["生产(MPS)", "仓储(收发)"],
@@ -368,10 +368,10 @@
    {"key": "finance", "name": "财务管理(会计核算)", "color": "#3ec9a7", "tag": "FIN", "flow": "finance",
     "positioning": "独立核算模块:会计核算 + èµ„产核算 + è´¢åŠ¡æŠ¥è¡¨çœ‹æ¿ï¼ˆä¸Ž ERP è¿›é”€å­˜è´¢åŠ¡å•æ®åˆ†ç¦»ï¼Œyudao-module-finance)",
     "problem": "进销存只管单据不管账,缺少会计科目/凭证/账簿与资产折旧摊销沉淀,无法出账表与资产净值",
     "features": ["会计科目树(约68条一级科目种子·可建下级)", "记账凭证(字号自动生成·分录借贷平衡前后端双重校验)", "凭证状态机 æœªè¿‡è´¦0→过账10/作废20,已过账不可改", "总账按期间逐月汇总", "明细账逐条分录并结转余额", "固定资产+计提折旧", "无形资产+计提摊销", "会计核算看板(收支/资产/凭证/月度趋势)", "附件走系统附件中心 recordType=fin_voucher"],
     "features": ["会计科目树(约68条一级科目种子·可建下级)", "记账凭证(字号自动生成·分录借贷平衡前后端双重校验)", "凭证状态机 æœªè¿‡è´¦0→过账10/作废20,已过账不可改", "总账按期间逐月汇总", "明细账逐条分录并结转余额", "固定资产+计提折旧", "无形资产+计提摊销", "会计核算看板(收支/资产/凭证/月度趋势)", "凭证来源单据关联(sourceType/sourceId/sourceNo,可回溯开票/回款/来票/付款)", "报表取数接入CRM客户应收对账/已发货未开票/未核销应收账龄/月度开票回款趋势", "附件走系统附件中心 recordType=fin_voucher"],
     "objects": ["会计科目", "记账凭证", "凭证分录", "固定资产", "无形资产"],
     "roles": ["会计", "财务主管", "资产会计", "管理层"],
     "upstream": ["进销存(ERP å·²å®¡æ ¸æ”¶è´§/付款/购销单据)"], "downstream": ["财务报表看板", "BI ç®¡ç†é©¾é©¶èˆ±"],
     "upstream": ["进销存(ERP å·²å®¡æ ¸æ”¶è´§/付款/购销单据)", "CRM(开票/回款/核销/对账报表)"], "downstream": ["财务报表看板", "BI ç®¡ç†é©¾é©¶èˆ±"],
     "coreTables": ["fin_account_subject", "fin_voucher", "fin_voucher_entry", "fin_fixed_asset", "fin_intangible_asset"]},
    {"key": "pro", "name": "生产管理", "color": "#e0b34f", "tag": "MES-PRO", "flow": "production",
     "positioning": "生产执行核心:计划-工单-任务-报工",
@@ -416,15 +416,15 @@
    {"key": "aftersales", "name": "售后服务", "color": "#c78bd6", "tag": "AFTER", "flow": "aftersales",
     "positioning": "客户反馈到退货/ç»´ä¿®/退款闭环",
     "problem": "售后工单流转、退货与质检入库退款的跨系统联动",
     "features": ["工单六态(草稿→处理中→待入库→待退款→完结→关闭)", "问题判定(一般/ç»´ä¿®/退货)", "ç»´ä¿®(现场/返厂/远程)", "退货申请→自动建 MES é”€å”®é€€è´§å•", "质检结论/入库/退款三重回调", "行级数据权限四视图"],
     "objects": ["售后工单", "维修记录", "退货申请"],
     "features": ["工单六态(草稿→处理中→待入库→待退款→完结→关闭)", "问题判定(一般/ç»´ä¿®/退货)", "客户诉求反馈描述+附件", "处理结果+附件", "工单流转记录", "ç»´ä¿®(现场/返厂/远程)", "退货由仓储物流发起→MES é”€å”®é€€è´§å•(原售后退货申请页面已下线)", "质检结论/入库/退款三重回调", "行级数据权限四视图"],
     "objects": ["售后工单", "工单流转记录", "维修记录", "退货申请(后台保留)"],
     "roles": ["客服", "维修工程师", "售后管理员"],
     "upstream": ["CRM(客户)", "ERP(销售订单)"], "downstream": ["仓储(销售退货)", "质量(RQC)", "财务(退款)"],
     "coreTables": ["after_sale_ticket", "after_sale_ticket_item", "after_sale_repair", "after_sale_return", "after_sale_permission"]},
    {"key": "hrm", "name": "人力资源", "color": "#9db85a", "tag": "HRM", "flow": null,
     "positioning": "员工入转调离与考勤薪酬",
     "problem": "人事流程审批化、离职数据不丢(交接)、算薪自动化",
     "features": ["员工档案(入职自动建系统账号)", "请假/加班/调岗/离职 BPM å®¡æ‰¹", "离职交接映射 expandUserIds", "考勤(打卡·规则·排班·补卡审批·月度汇总)", "薪酬(应发-社保公积金-个税-请假扣款·1.5/2/3倍加班费·发放台账)", "培训证书(SQL先行待实现)"],
     "features": ["员工档案(入职自动建系统账号)", "请假/加班/调岗/离职 BPM å®¡æ‰¹", "离职交接同时转移员工ID/用户ID/业务数据 expandUserIds", "离职到期自动把员工状态改为离职并停用系统账号", "考勤(打卡·规则·排班·补卡审批·月度汇总)", "薪酬(应发-社保公积金-个税-请假扣款·1.5/2/3倍加班费·发放台账)", "培训证书(SQL先行待实现)"],
     "objects": ["员工", "请假单", "离职申请", "交接映射", "考勤记录", "薪酬核算"],
     "roles": ["HR", "部门主管", "员工本人"],
     "upstream": [], "downstream": ["各模块(数据可见性扩展)", "system(账号)"],
@@ -462,20 +462,21 @@
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB é…ç½®å¼é˜¶æ®µæŽ¨è¿›ï¼Œç»“束态: èµ¢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business èµ¢å• è¾“单 é˜¶æ®µ"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM ç‰©æ–™æŠ¥ä»·ï¼ŒAI OCR å½•入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM å®¡æ‰¹(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单;支持 Excel å¯¼å…¥ï¼ˆä¸€åˆåŒå¤šäº§å“æ˜Žç»†è¡Œã€åˆå¹¶å•元格自动拉平,客户与物料按名称/编码校验);产品明细带出物料名称/编码/条码/规格型号/计量单位;删除合同时连带删除产品明细,不留孤儿数据", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract å®¡æ‰¹ å¯¼å…¥"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;支持 Excel å¯¼å…¥ï¼ˆä»…新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验", "upstream": ["合同", "发票"], "downstream": ["收款单(关联)"], "keywords": "回款 æ”¶æ¬¾è®¡åˆ’ receivable"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号(可留空自动生成、系统内唯一),BPM å®¡æ‰¹ï¼Œç´¯è®¡å¼€ç¥¨â‰¤åˆåŒé¢ï¼Œé™„件必传;开票申请须绑定 ERP é”€å”®è®¢å•与 MES é”€å”®å‡ºåº“单,审批通过后进入录入发票阶段(entry_status 0待录入→10已录入),录入时填写发票号码/日期/附件并设置合同款/预付款/到货款/验收款/质保金收款节点;支持 Excel å¯¼å…¥ï¼ˆä»…新增不更新,合同须审核通过且本次金额≤合同剩余可开票额;同一批数据不可重复导入——同合同下发票号码相同、或发票号码留空时开票金额+发票抬头+开票日期相同即判重复,文件内重复行与库内已有单都拒该行并回显维度与实际值)", "upstream": ["合同", "销售订单", "销售出库单"], "downstream": ["回款", "回款计划", "记账凭证"], "keywords": "开票 å‘票 invoice å¯¼å…¥ åŽ»é‡ å¼€ç¥¨ç”³è¯· å½•入发票 å‡ºåº“单"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验;新增/编辑可带核销明细 items(20销售订单/21销售出库/23开票),明细合计必须等于回款金额,审核通过后回写回款计划已回款额/实际回款时间/状态(0未回款10部分回款20已回清)", "upstream": ["合同", "发票", "销售订单", "销售出库单"], "downstream": ["收款单(关联)", "回款核销明细", "回款计划", "记账凭证"], "keywords": "回款 æ”¶æ¬¾è®¡åˆ’ receivable æ ¸é”€"},
    {"name": "回款核销明细", "module": "销售管理", "table": "crm_receivable_item", "doc": "每行把回款金额核销到一张业务单据:biz_type 20销售订单/21销售出库/23开票,biz_no、单据金额、已核销金额由后端按 biz_id å›žå¡«ï¼›æ¯è¡Œæ ¸é”€é¢ä¸å¾—超过该单据剩余应收;不传 items ä¸”带 invoice_id æ—¶è‡ªåŠ¨ç”Ÿæˆä¸€æ¡å¼€ç¥¨æ ¸é”€è¡Œ", "upstream": ["回款", "销售订单", "销售出库单", "发票"], "downstream": ["客户应收对账", "未核销应收报表"], "keywords": "核销 æ ¸é”€æ˜Žç»† write off item receivable_item"},
    {"name": "销售目标", "module": "销售管理", "table": "crm_sales_target", "doc": "å¹´/å­£/月×个人/部门/产品维度的合同回款等目标", "upstream": [], "downstream": ["业绩统计"], "keywords": "目标 target ä¸šç»©"},
    {"name": "供应商", "module": "供应商管理", "table": "srm_supplier", "doc": "供应商档案:执照/税号;多个联系人(srm_supplier_contact)与多个银行账户(srm_supplier_bank·含银行行号),主表保留默认联系人/默认银行字段,ERP ä¸Ž MES å…±ç”¨", "upstream": ["手工建档/Excel å¯¼å…¥"], "downstream": ["询价比价", "投标保证金", "采购订单", "到货通知", "供应商退货", "供应商往来"], "keywords": "供应商 supplier vendor è”系人 å¼€æˆ·è¡Œ é“¶è¡Œè¡Œå·"},
    {"name": "询价比价", "module": "供应商管理", "table": "srm_inquiry", "doc": "XJ编号,草稿0→已转订单20→已取消30(10询价中为历史状态);新增/编辑时一并录入产品行×参与供应商报价(srm_inquiry_quote)并选定每个产品的中标供应商,无需单独的报价/比价步骤,确认后按中标供应商拆分生成采购订单草稿并记录 srm_inquiry_order", "upstream": ["供应商", "主数据(物料)"], "downstream": ["采购订单(草稿)"], "keywords": "询价 æ¯”ä»· æŠ¥ä»· ä¸­æ ‡ inquiry quote"},
    {"name": "投标保证金", "module": "供应商管理", "table": "srm_bid_bond", "doc": "BZ编号台账,关联供应商/询价单/采购订单,记录保证金金额、缴纳日期、是否中标、退回状态(0未退1部分2已退3无需退)/退回金额/退回日期", "upstream": ["供应商", "询价比价", "采购订单"], "downstream": ["供应商往来(参考)"], "keywords": "投标保证金 ä¸­æ ‡ é€€å›ž bid bond"},
    {"name": "采购申请", "module": "进销存", "table": "erp_purchase_request", "doc": "CGSQ编号,BPM å®¡æ‰¹ï¼Œé€šè¿‡åŽæœ‰ä¾›åº”商自动生成订单", "upstream": ["生产缺口(人工)"], "downstream": ["采购订单"], "keywords": "采购申请 è¯·è´­ request"},
    {"name": "采购订单", "module": "进销存", "table": "erp_purchase_order", "doc": "CGDD编号,BPM å®¡æ‰¹(草稿0→审批中15→已审核20),入库状态:未0部分1全部2;来源可为采购申请或询价比价确认生成的草稿(来源单号回溯);单价为含税单价,价税合计=单价×数量,税额从含税金额中反算(不再额外加税)", "upstream": ["采购申请", "询价比价(确认中标)"], "downstream": ["到货通知", "采购来票", "供应商退货(约束物料)", "供应商往来"], "keywords": "采购订单 purchase"},
    {"name": "采购来票", "module": "进销存", "table": "erp_purchase_invoice", "doc": "FPD编号,AI OCR é¢„填,累计≤订单总价,付款单的前置;供应商往来右侧展示开票金额/已付/剩余应付", "upstream": ["采购订单"], "downstream": ["付款单", "供应商往来"], "keywords": "来票 å‘票 é‡‡è´­å‘票"},
    {"name": "采购来票", "module": "进销存", "table": "erp_purchase_invoice", "doc": "FPD编号,AI OCR é¢„填,累计≤订单总价,付款单的前置;必须同时绑定采购订单与 MES é‡‡è´­å…¥åº“单(item_receipt_id),按订单与入库单两层剩余可来票额度校验;供应商往来右侧展示开票金额/已付/剩余应付", "upstream": ["采购订单", "MES é‡‡è´­å…¥åº“单"], "downstream": ["付款单", "供应商往来", "记账凭证"], "keywords": "来票 å‘票 é‡‡è´­å‘票 å…¥åº“单"},
    {"name": "付款单", "module": "财务", "table": "erp_finance_payment", "doc": "FKD编号,必须关联来票+附件,累计≤来票金额,BPM å®¡æ‰¹", "upstream": ["采购来票"], "downstream": ["结算账户", "供应商往来"], "keywords": "付款 åº”付 payment"},
    {"name": "供应商往来", "module": "进销存", "table": "无独立表(聚合视图)", "doc": "按供应商+时间范围聚合采购订单/来票/付款:汇总累计采购额、累计开票额、累计付款额、未付余额;左侧订单行含已开票/未开票金额,右侧来票行含开票金额/已付/剩余应付", "upstream": ["采购订单", "采购来票", "付款单"], "downstream": [], "keywords": "供应商往来 å¯¹è´¦ å¼€ç¥¨é‡‘额 åº”付 statement"},
    {"name": "收款单", "module": "财务", "table": "erp_finance_receipt", "doc": "SKD编号,关联 CRM å®¢æˆ·æ ¡éªŒï¼Œæ—  BPM(手动审核)", "upstream": ["销售(弱关联)"], "downstream": ["结算账户"], "keywords": "收款 åº”æ”¶ receipt"},
    {"name": "会计科目", "module": "会计核算", "table": "fin_account_subject", "doc": "科目树,parent_id è‡ªå…³è”;科目类型1资产2负债3权益4成本5损益,余额方向1借2贷,状态0启用1禁用;账表与分录统一按 subject_code å–æ•°", "upstream": ["会计科目种子约68条一级科目"], "downstream": ["记账凭证分录", "总账", "明细账"], "keywords": "会计科目 ç§‘ç›® subject account èµ„产类 è´Ÿå€ºç±» æŸç›Šç±»"},
    {"name": "记账凭证", "module": "会计核算", "table": "fin_voucher", "doc": "凭证字号可留空由后端自动生成;状态 æœªè¿‡è´¦0/已过账10/已作废20;已过账不可改,未过账与已过账均可作废,未过账与已作废可删除;附件按 recordType=fin_voucher æŒ‚系统附件中心", "upstream": ["会计科目"], "downstream": ["总账", "明细账", "财务报表看板"], "keywords": "记账凭证 å‡­è¯ voucher è¿‡è´¦ ä½œåºŸ å€Ÿè´·å¹³è¡¡"},
    {"name": "记账凭证", "module": "会计核算", "table": "fin_voucher", "doc": "凭证字号可留空由后端自动生成;状态 æœªè¿‡è´¦0/已过账10/已作废20;已过账不可改,未过账与已过账均可作废,未过账与已作废可删除;可通过 source_type/source_id/source_no å…³è”开票/回款/来票/付款等来源单据并支持按来源单号检索;附件按 recordType=fin_voucher æŒ‚系统附件中心", "upstream": ["会计科目", "开票", "回款", "采购来票", "付款单"], "downstream": ["总账", "明细账", "财务报表看板"], "keywords": "记账凭证 å‡­è¯ voucher è¿‡è´¦ ä½œåºŸ å€Ÿè´·å¹³è¡¡"},
    {"name": "凭证分录", "module": "会计核算", "table": "fin_voucher_entry", "doc": "凭证下逐行分录:科目编码+借/贷金额+辅助核算(1客户2供应商3部门4员工5项目);一行不可借贷同时大于0,全表借贷合计必须相等且大于0", "upstream": ["记账凭证", "会计科目"], "downstream": ["总账", "明细账"], "keywords": "凭证分录 åˆ†å½• entry å€Ÿæ–¹ è´·æ–¹ è¾…助核算"},
    {"name": "固定资产", "module": "会计核算", "table": "fin_fixed_asset", "doc": "资产卡片,状态 1在用/2闲置/3维修中/4已报废;月折旧额=(原值×(1-残值率))÷(年限×12),累计折旧与净值由计提累加,不接收前端传值", "upstream": [], "downstream": ["财务报表看板", "总账"], "keywords": "固定资产 æŠ˜æ—§ èµ„产卡片 fixed asset æ®‹å€¼çއ"},
    {"name": "无形资产", "module": "会计核算", "table": "fin_intangible_asset", "doc": "无形资产卡片(证书编号/有效期),状态 1在用/2已失效/3已摊销完;月摊销额=(原值×(1-残值率))÷(摊销年限×12),累计摊销与净值由计提累加", "upstream": [], "downstream": ["财务报表看板", "总账"], "keywords": "无形资产 æ‘Šé”€ è½¯ä»¶è‘—作权 ä¸“利 intangible æœ‰æ•ˆæœŸ"},
@@ -489,7 +490,7 @@
    {"name": "采购入库单", "module": "仓储", "table": "mes_wm_item_receipt", "doc": "草稿0→待上架2→待执行3→完成4,完成后库存+并回写 ERP inCount/inStatus", "upstream": ["到货通知"], "downstream": ["库存台账", "采购订单(回写)"], "keywords": "采购入库 æ”¶è´§å…¥åº“ receipt"},
    {"name": "生产领料单", "module": "仓储", "table": "mes_wm_product_issue", "doc": "草稿0→待拣货2→待执行3→完成4,库存-", "upstream": ["生产工单"], "downstream": ["库存台账"], "keywords": "领料 issue"},
    {"name": "销售出库单", "module": "仓储", "table": "mes_wm_product_sales", "doc": "发货通知审核后生成,提交→OQC检测→拣货→发运10→待执行3→完成4,回写销售订单出库数与状态", "upstream": ["发货通知", "销售订单"], "downstream": ["库存台账", "OQC"], "keywords": "销售出库 å‘è´§ å‡ºåº“单 sales"},
    {"name": "销售退货单", "module": "仓储", "table": "mes_wm_return_sales", "doc": "草稿→待检验(RQC)→待上架→待入库→完成,售后与手工两来源", "upstream": ["售后退货申请"], "downstream": ["库存台账", "RQC"], "keywords": "销售退货 é€€è´§å…¥åº“"},
    {"name": "销售退货单", "module": "仓储", "table": "mes_wm_return_sales", "doc": "草稿→待检验(RQC)→待上架→待入库→完成,售后与手工两来源", "upstream": ["售后退货(仓储发起)"], "downstream": ["库存台账", "RQC"], "keywords": "销售退货 é€€è´§å…¥åº“"},
    {"name": "供应商退货单", "module": "仓储", "table": "mes_wm_return_vendor", "doc": "IQC ä¸åˆæ ¼é€€è´§å‘起,待拣货→待执行→完成,库存-", "upstream": ["IQC"], "downstream": ["库存台账", "采购订单(returnCount)"], "keywords": "供应商退货 é€€æ–™ é€€è´§ vendor"},
    {"name": "调拨单", "module": "仓储", "table": "mes_wm_transfer", "doc": "内部/外部调拨,成对 MOVE_OUT+MOVE_IN äº‹åŠ¡ï¼Œåœ¨é€”æ•°é‡", "upstream": [], "downstream": ["库存台账"], "keywords": "调拨 ç§»åº“ transfer"},
    {"name": "盘点任务", "module": "仓储", "table": "mes_wm_stock_taking_task", "doc": "静态/动态盘点,行结果:正常/盘盈(ADJUST_IN)/盘亏(ADJUST_OUT),可冻结库存、盲盘", "upstream": ["库存台账"], "downstream": ["库存台账(调整)"], "keywords": "盘点 ç›˜ç›ˆ ç›˜äº stock taking"},
@@ -501,10 +502,12 @@
    {"name": "RQC退货检验", "module": "质量", "table": "mes_qc_rqc", "doc": "生产退料/销售退货/售后退货触发,结论回调售后;行操作「出报告」仅在已完成时可见。措辞已统一为「退货检验」(原菜单父项与 mes_qc_type å­—典写「退料检验」,与后端枚举/表注释/菜单权限子项不一致;RQC ä¸‰è€…都覆盖,故取「退货检验」)", "upstream": ["退料", "退货"], "downstream": ["退货入库", "售后回调", "报告实例(出报告)"], "keywords": "RQC é€€è´§æ£€éªŒ é€€æ–™æ£€éªŒ å‡ºæŠ¥å‘Š"},
    {"name": "NCR不合格品", "module": "质量", "table": "mes_qc_ncr", "doc": "待评审0→已评审6→已处置7→已关闭8,处置:退货/报废/返工/让步接收/降级", "upstream": ["IQC/IPQC/OQC/RQC"], "downstream": ["退货/报废/返工(台账)"], "keywords": "NCR ä¸åˆæ ¼ å¤„ç½® ç‰¹é‡‡ æŠ¥åºŸ"},
    {"name": "设备", "module": "设备", "table": "mes_dv_machinery", "doc": "台账状态:停机/生产中/保养中;点检·保养·维修工单;AI é£Žé™©é¢„测消费其数据", "upstream": [], "downstream": ["点检", "ç»´ä¿®", "生产风险预测"], "keywords": "设备 ç‚¹æ£€ ä¿å…» ç»´ä¿® machinery"},
    {"name": "售后工单", "module": "售后", "table": "after_sale_ticket", "doc": "草稿0→处理中10→待入库30→待退款40→完结50→关闭60/取消99,判定:一般/ç»´ä¿®/退货", "upstream": ["客户反馈(人工录入)"], "downstream": ["维修记录", "退货申请"], "keywords": "售后 å·¥å• ticket ç»´ä¿®"},
    {"name": "退货申请", "module": "售后", "table": "after_sale_return", "doc": "草稿0→已审核20(自动建MES退货单)→已质检40→已入库50→已退款60→关闭70;类型:退款退货/换货/仅退款", "upstream": ["售后工单"], "downstream": ["销售退货单", "退款"], "keywords": "售后退货 return"},
    {"name": "员工", "module": "人力", "table": "hrm_employee", "doc": "在职1/试用2/离职3,入职自动建系统账号", "upstream": [], "downstream": ["交接映射", "考勤", "薪酬"], "keywords": "员工 å…¥èŒ employee"},
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "from→to æ˜ å°„,已完成20 åŽ expandUserIds æ‰©å±•交接人可见数据(当前各业务模块尚未实际接入调用)", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询"], "keywords": "交接 ç¦»èŒ handover expandUserIds"},
    {"name": "售后工单", "module": "售后", "table": "after_sale_ticket", "doc": "草稿0→处理中10→待入库30→待退款40→完结50→关闭60/取消99,判定:一般/ç»´ä¿®/退货;客户诉求反馈描述+附件、处理结果+附件;创建/更新/判定/完结/归档/取消/回访写入流转记录", "upstream": ["客户反馈(人工录入)"], "downstream": ["维修记录", "仓储销售退货(原退货申请页面已下线)"], "keywords": "售后 å·¥å• ticket ç»´ä¿® å®¢æˆ·è¯‰æ±‚ å¤„理结果 æµè½¬è®°å½•"},
    {"name": "工单流转记录", "module": "售后", "table": "after_sale_ticket_flow", "doc": "售后工单每次动作留痕:动作编码/名称、变更前后状态、内容、操作人、时间", "upstream": ["售后工单"], "downstream": [], "keywords": "售后 æµè½¬è®°å½• flow"},
    {"name": "退货申请", "module": "售后", "table": "after_sale_return", "doc": "后台保留,售后页面已下线;草稿0→已审核20(自动建MES退货单)→已质检40→已入库50→已退款60→关闭70;类型:退款退货/换货/仅退款", "upstream": ["售后工单"], "downstream": ["销售退货单", "退款"], "keywords": "售后退货 return"},
    {"name": "员工", "module": "人力", "table": "hrm_employee", "doc": "在职1/试用2/离职3,入职自动建系统账号;离职申请到期自动置为离职3并停用系统账号", "upstream": [], "downstream": ["交接映射", "考勤", "薪酬"], "keywords": "员工 å…¥èŒ employee ç¦»èŒ"},
    {"name": "离职申请", "module": "人力", "table": "hrm_resignation_application", "doc": "草稿0→审批中10→通过20/驳回30/撤销40;审批通过后可交接0→10→20;到期任务写实际离职日期、把员工状态改为离职3并停用系统账号", "upstream": ["员工"], "downstream": ["员工档案状态", "离职交接"], "keywords": "离职 resignation å®¡æ‰¹ äº¤æŽ¥ è‡ªåŠ¨ç¦»èŒ"},
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "同时记录离职/交接双方员工ID与用户ID;交接完成20 åŽ expandUserIds æ‰©å±•交接人可见数据;审批通过且到预计离职日期后自动把员工状态改为离职3并停用系统账号", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询", "员工档案状态"], "keywords": "交接 ç¦»èŒ handover expandUserIds è‡ªåŠ¨ç¦»èŒ"},
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"},
    {"name": "报告模板", "module": "质检报告", "table": "qc_report_template", "doc": "智能质检报告模板,启用0/停用1;画布内容存在 qc_report_template_version(草稿0/已发布1/已停用2,已发布不可覆盖),currentVersion æŒ‡å‘当前已发布版本;源文件经 AI å¯¼å…¥è¯†åˆ«æˆè‰ç¨¿åŽç”±äººå·¥ç¡®è®¤æ‰æˆä¸ºç‰ˆæœ¬ï¼›åŒä¸€æ¨¡æ¿é‡å¤å¯¼å…¥æŒ‰ã€æ›¿æ¢ã€‘处理——先删旧附件(连带 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ï¼‰å†ç»‘新的,不留孤儿文件;删除模板会连带清附件", "upstream": ["人工设计(GrapesJS è®¾è®¡å™¨)", "AI å¯¼å…¥è‰ç¨¿(qc-report/ai-import/draft)"], "downstream": ["模板版本", "报告实例"], "keywords": "报告模板 è´¨æ£€æŠ¥å‘Š template è®¾è®¡å™¨ ç‰ˆæœ¬ AI导入 è¯†åˆ« è‰ç¨¿ æºæ–‡ä»¶æ›¿æ¢"},
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"}
@@ -561,12 +564,13 @@
       {"stage": "盘点", "action": "方案→任务→明细行→结果,盘盈 ADJUST_IN、盘亏 ADJUST_OUT,可冻结与盲盘", "roles": ["盘点员"]},
       {"stage": "追溯", "action": "批次/SN(uuid)/条码(18ç±»)/装箱嵌套支撑正反向追溯", "roles": ["质量", "售后"]}]},
    {"id": "aftersales", "name": "售后流程", "mermaid": "mermaid/07-after-sales-flow.mmd", "color": "#c78bd6",
     "summary": "工单(六态+问题判定) â†’ ç»´ä¿®ä¸‰ç±»åž‹æˆ–退货申请 â†’ å®¡æ ¸è‡ªåŠ¨å»º MES é”€å”®é€€è´§å• â†’ RQC è´¨æ£€/入库/退款三回调逐级推进 â†’ å·¥å•完结关闭。",
     "summary": "工单(六态+问题判定) â†’ å®¢æˆ·è¯‰æ±‚/处理结果附件与流转记录留痕 â†’ ç»´ä¿®ä¸‰ç±»åž‹æˆ–由仓储物流发起销售退货 â†’ RQC è´¨æ£€/入库/退款三回调逐级推进 â†’ å·¥å•完结关闭。",
     "steps": [
       {"stage": "受理", "action": "人工录入工单(关联客户/订单/合同),分配处理人进入处理中", "roles": ["客服"]},
       {"stage": "受理", "action": "人工录入工单(关联客户/订单/合同),填写客户诉求反馈描述并上传附件,创建时指定的处理人直接进入处理中", "roles": ["客服"]},
       {"stage": "判定", "action": "问题判定: ä¸€èˆ¬/ç»´ä¿®/退货,严重度 0-3", "roles": ["售后工程师"]},
       {"stage": "处理", "action": "填写处理结果并上传处理结果附件;创建、更新、判定、完结、归档、取消、回访均写入流转记录", "roles": ["售后工程师"]},
       {"stage": "ç»´ä¿®", "action": "现场/返厂/远程维修,费用与结果记录,完成联动工单完结", "roles": ["维修工程师"]},
       {"stage": "退货", "action": "退货申请审核→自动建 MES é”€å”®é€€è´§å•(RQC)→质检/入库回调→退款回调→关闭", "roles": ["售后", "质检", "财务"]}]},
       {"stage": "退货", "action": "售后不再发起退货申请,由仓储物流发起销售退货单(RQC)→质检/入库回调→退款回调→关闭", "roles": ["仓储", "质检", "财务"]}]},
    {"id": "finance", "name": "会计核算流程", "mermaid": "mermaid/12-finance-flow.mmd", "color": "#3ec9a7",
     "summary": "独立核算模块 yudao-module-finance:会计科目建账 â†’ è®°è´¦å‡­è¯å½•å…¥(借贷平衡校验) â†’ è¿‡è´¦ â†’ æ€»è´¦/明细账 â†’ èµ„产计提折旧与摊销 â†’ è´¢åŠ¡æŠ¥è¡¨çœ‹æ¿å–æ•° ERP å·²å®¡æ ¸å•据。",
     "steps": [
@@ -604,7 +608,7 @@
    {"role": "仓管员", "modules": ["MES-WM"], "operations": ["15 ç±»å‡ºå…¥åº“单据执行", "调拨", "盘点", "条码装箱"], "data": ["mes_wm_*"], "scope": "仓库维度;WMS å¯æŒ‰ä»“库配置出入库/调拨/盘点是否走 BPM"},
    {"role": "质检员", "modules": ["MES-QC"], "operations": ["IQC/IPQC/OQC/RQC æ£€éªŒ", "NCR è¯„审处置"], "data": ["mes_qc_*"], "scope": "inspectorUserId å½’属"},
    {"role": "设备员/维修工", "modules": ["MES-DV"], "operations": ["点检保养", "维修工单", "工装领用"], "data": ["mes_dv_*", "mes_tm_*"], "scope": "chargeUserId å½’属"},
    {"role": "客服/售后工程师", "modules": ["售后"], "operations": ["工单受理派工", "ç»´ä¿®", "退货申请"], "data": ["after_sale_*"], "scope": "行级数据权限四视图:我负责/我参与/下属负责/全部(aftersales:view-all)"},
    {"role": "客服/售后工程师", "modules": ["售后"], "operations": ["工单受理派工", "客户诉求/处理结果附件", "ç»´ä¿®", "退货转仓储发起"], "data": ["after_sale_*"], "scope": "行级数据权限四视图:我负责/我参与/下属负责/全部(aftersales:view-all)"},
    {"role": "财务", "modules": ["ERP", "CRM", "FIN"], "operations": ["收款单", "付款单", "开票审核", "结算账户", "会计科目维护", "记账凭证录入与过账/作废", "总账与明细账查询", "固定资产折旧计提", "无形资产摊销计提", "会计核算看板"], "data": ["erp_finance_*", "crm_invoice", "crm_receivable", "fin_account_subject", "fin_voucher", "fin_voucher_entry", "fin_fixed_asset", "fin_intangible_asset"], "scope": "financeUserId å½’属;财务核算模块按 finance:* æŽ¥å£æƒé™ + system_menu æŽˆæƒï¼Œæš‚无数据权限字段"},
    {"role": "HR", "modules": ["HRM"], "operations": ["员工档案入转调离", "考勤规则排班", "薪酬核算发放", "离职交接"], "data": ["hrm_*"], "scope": "部门范围 dataScope"},
    {"role": "管理层", "modules": ["BI"], "operations": ["查看采购销售/仓库/生产设备/质量/人力五大主题大屏"], "data": ["bi_dashboard"], "scope": "只读"}
@@ -688,9 +692,10 @@
      å·¥è£…夹具
      ç­ç»„班次与排班
    å”®åŽæœåŠ¡
      å”®åŽå·¥å•
      å”®åŽå·¥å•(客户诉求/处理结果附件)
      å·¥å•流转记录
      ç»´ä¿®è®°å½•
      é€€è´§ç”³è¯·
      é€€è´§æ“ä½œ(仓储物流发起,原退货申请页面已下线)
      æ•°æ®æƒé™
    äººåŠ›èµ„æº HRM
      å‘˜å·¥æ¡£æ¡ˆ
@@ -922,11 +927,12 @@
</pre>
<pre style="display:none" class="mmd-src" data-mmd="07-after-sales-flow">
flowchart TD
  F1[客户反馈·后台人工录入工单·关联客户联系人销售订单合同] --> K1[售后工单·草稿0]
  K1 -->|assignTicket åˆ†é…å¤„理人| K2[处理中10·问题判定:一般1ç»´ä¿®2退货3·严重度0-3]
  F1[客户反馈·后台人工录入工单·关联客户联系人销售订单合同·客户诉求反馈+附件] --> K1[售后工单·草稿0]
  K1 -->|创建时指定处理人,直接进入| K2[处理中10·问题判定:一般1ç»´ä¿®2退货3·严重度0-3]
  K2 -->|维修类| R1[维修记录·待处理0→维修中10→已完成20→已关闭30·现场返厂远程]
  R1 -->|completeRepair| K4[已完结50]
  K2 -->|退货类创建退货申请| T1[退货申请·草稿0]
  K1 -.->|创建/更新/判定/完结/归档/取消/回访均留痕| FL[工单流转记录 after_sale_ticket_flow]
  K2 -->|退货类→仓储物流发起销售退货| T1[退货申请·草稿0·售后页面已下线,后台保留]
  T1 -->|approveReturn å®¡æ ¸é€šè¿‡| T2[已审核20·自动创建MES销售退货单]
  T2 --> T3[MES é”€å”®é€€è´§Â·è‰ç¨¿â†’待检验RQC→待上架→待入库→完成]
  T3 -->|质检结论回调 onReturnQualityFinished åˆæ ¼ç‰¹é‡‡é€€è´§æŠ¥åºŸ| T4[已质检40]
@@ -936,7 +942,7 @@
  T2 -.->|状态流转中| T8[审核中10·预留未启用]
  K2 -->|退货链路联动工单| K3[待入库30→待退款40]
  K3 --> K4
  K2 -->|resolveTicket| K4
  K2 -->|resolveTicket å¡«å†™å¤„理结果+附件| K4
  K4 -->|closeTicket| K5[已关闭60]
  K1 -->|cancelTicket| K9[已取消99]
  K2 -->|cancelTicket| K9
@@ -1096,7 +1102,8 @@
  mes_wm_item_receipt ||--o{ mes_wm_material_stock : "库存增加"
  mes_wm_material_stock ||--o{ mes_wm_transaction : "事务流水"
  mes_wm_material_stock }o--|| mes_wm_batch : "按批次"
  after_sale_ticket ||--o{ after_sale_return : "退货申请"
  after_sale_ticket ||--o{ after_sale_ticket_flow : "流转记录"
  after_sale_ticket ||--o{ after_sale_return : "退货申请(后台保留)"
  after_sale_return ||--o| mes_wm_return_sales : "生成MES退货单"
  mdm_item ||--o{ erp_sale_order_items : "销售明细"
  mdm_item ||--o{ erp_purchase_order_items : "采购明细"
@@ -1177,6 +1184,26 @@
  M --> K
  O --> K
  P[ERP å·²å®¡æ ¸å•据 status=20 æ”¶æ¬¾å• ä»˜æ¬¾å• é”€å”®è®¢å• é‡‡è´­è®¢å•] -->|ErpFinanceStatisticsApi.getMonthlyStatistics æŒ‰æœˆ| K
  subgraph é”€å”®é‡‡è´­è´¢åŠ¡é—­çŽ¯
    R1[ERP é”€å”®è®¢å• status=20] -->|选择订单| R2[MES é”€å”®å‡ºåº“单 status=4 å·²å®Œæˆ]
    R2 -->|开票申请·绑定订单+出库单| R3[CRM å¼€ç¥¨ crm_invoice entry_status=0 å¾…录入]
    R3 -->|审批通过| R4[录入发票 invoiceNo/日期/附件]
    R4 -->|填写收款节点| R5[回款计划 crm_receivable_plan stage=1..5]
    R4 -->|entry_status=10 å·²å½•å…¥| R6[CRM å›žæ¬¾ crm_receivable]
    R6 -->|核销明细 crm_receivable_item bizType=20/21/23| R7[销售订单 / é”€å”®å‡ºåº“ / å¼€ç¥¨]
    R7 -->|订单金额-已回款| R8[客户应收对账单]
    R5 -->|下月未回清节点| R8
    R2 -->|已完成出库-已开票| R9[已发货未开票报表]
    R4 -->|开票-已回款| R10[未核销应收报表·账龄]
    R8 --> R11[下月回款计划]
    R1 -->|选择订单| P1[ERP é‡‡è´­è®¢å• status=20]
    P1 -->|选择订单| P2[MES é‡‡è´­å…¥åº“单 mes_wm_item_receipt status=4]
    P2 -->|来票·绑定订单+入库单| P3[ERP æ¥ç¥¨ erp_purchase_invoice item_receipt_id]
    P3 -->|剩余可来票| P1
  end
  R4 -->|sourceType=CRM_INVOICE| C1[记账凭证 fin_voucher source_type/source_id/source_no]
  R6 -->|sourceType=CRM_RECEIVABLE| C1
  P3 -->|sourceType=ERP_PURCHASE_INVOICE| C1
  K -.->|科目数 å‡­è¯æ•° æœªè¿‡è´¦ å·²è¿‡è´¦ å·²ä½œåºŸ èµ„产净值 æœˆåº¦è¶‹åŠ¿| Q[看板卡片+月度趋势图]
  V[附件中心 recordType=fin_voucher] --- C
  V -.->|不落 file_url åˆ—| C
docs/project-business/mermaid/01-business-mindmap.mmd
@@ -75,9 +75,10 @@
      å·¥è£…夹具
      ç­ç»„班次与排班
    å”®åŽæœåŠ¡
      å”®åŽå·¥å•
      å”®åŽå·¥å•(客户诉求/处理结果附件)
      å·¥å•流转记录
      ç»´ä¿®è®°å½•
      é€€è´§ç”³è¯·
      é€€è´§æ“ä½œ(仓储物流发起,原退货申请页面已下线)
      æ•°æ®æƒé™
    äººåŠ›èµ„æº HRM
      å‘˜å·¥æ¡£æ¡ˆ
docs/project-business/mermaid/07-after-sales-flow.mmd
@@ -1,9 +1,10 @@
flowchart TD
  F1[客户反馈·后台人工录入工单·关联客户联系人销售订单合同] --> K1[售后工单·草稿0]
  K1 -->|assignTicket åˆ†é…å¤„理人| K2[处理中10·问题判定:一般1ç»´ä¿®2退货3·严重度0-3]
  F1[客户反馈·后台人工录入工单·关联客户联系人销售订单合同·客户诉求反馈+附件] --> K1[售后工单·草稿0]
  K1 -->|创建时指定处理人,直接进入| K2[处理中10·问题判定:一般1ç»´ä¿®2退货3·严重度0-3]
  K2 -->|维修类| R1[维修记录·待处理0→维修中10→已完成20→已关闭30·现场返厂远程]
  R1 -->|completeRepair| K4[已完结50]
  K2 -->|退货类创建退货申请| T1[退货申请·草稿0]
  K1 -.->|创建/更新/判定/完结/归档/取消/回访/仓储退货均留痕| FL[工单流转记录 after_sale_ticket_flow]
  K2 -->|退货类→仓储物流发起销售退货| T1[退货申请·草稿0·售后页面已下线,后台保留]
  T1 -->|approveReturn å®¡æ ¸é€šè¿‡| T2[已审核20·自动创建MES销售退货单]
  T2 --> T3[MES é”€å”®é€€è´§Â·è‰ç¨¿â†’待检验RQC→待上架→待入库→完成]
  T3 -->|质检结论回调 onReturnQualityFinished åˆæ ¼ç‰¹é‡‡é€€è´§æŠ¥åºŸ| T4[已质检40]
@@ -11,9 +12,11 @@
  T5 -->|退款完成回调 onRefundCompleted è®°å½•实际退款额| T6[已退款60]
  T6 --> T7[已关闭70]
  T2 -.->|状态流转中| T8[审核中10·预留未启用]
  K2 -->|退货链路联动工单| K3[待入库30→待退款40]
  K2 -->|退货链路联动工单| K3[待退货30→待退款40]
  W1[仓储物流·创建销售退货单·关联销售订单] -.->|onWarehouseReturnCreated å†™å…¥ä»“储退货记录 RETURN| FL
  W1 -->|匹配该订单最近一张未关闭/未取消工单,草稿或处理中则推进| K3
  K3 --> K4
  K2 -->|resolveTicket| K4
  K2 -->|resolveTicket å¡«å†™å¤„理结果+附件| K4
  K4 -->|closeTicket| K5[已关闭60]
  K1 -->|cancelTicket| K9[已取消99]
  K2 -->|cancelTicket| K9
docs/project-business/mermaid/10-business-object.mmd
@@ -24,7 +24,8 @@
  mes_wm_item_receipt ||--o{ mes_wm_material_stock : "库存增加"
  mes_wm_material_stock ||--o{ mes_wm_transaction : "事务流水"
  mes_wm_material_stock }o--|| mes_wm_batch : "按批次"
  after_sale_ticket ||--o{ after_sale_return : "退货申请"
  after_sale_ticket ||--o{ after_sale_ticket_flow : "流转记录"
  after_sale_ticket ||--o{ after_sale_return : "退货申请(后台保留)"
  after_sale_return ||--o| mes_wm_return_sales : "生成MES退货单"
  mdm_item ||--o{ erp_sale_order_items : "销售明细"
  mdm_item ||--o{ erp_purchase_order_items : "采购明细"
docs/purchase_flow_frontend_guide.md
@@ -10,7 +10,7 @@
## ä¸šåŠ¡æµç¨‹
```
采购申请审批通过 â†’ è‡ªåŠ¨ç”Ÿæˆé‡‡è´­è®¢å•(已审核)
采购申请审批通过 â†’ è‡ªåŠ¨ç”Ÿæˆé‡‡è´­è®¢å•(草稿,补充付款日期/采购类型等字段后由采购台账提交审批)
    â†’ ç‚¹å‡»"确认收货" â†’ ç”Ÿæˆåˆ°è´§é€šçŸ¥å•(待入库/待质检)
    â†’ IQC质检(如需要) â†’ é‡‡è´­å…¥åº“
```
docs/sql/config_export_all_20261104.sql
@@ -435,7 +435,7 @@
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=71 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='MES ç¼–码规则';
INSERT INTO `mes_md_auto_code_rule` (`id`, `code`, `name`, `description`, `max_length`, `padded`, `padded_char`, `padded_method`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1,'MD_ITEM_TYPE_CODE','物料分类编码','物料分类自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(2,'MD_ITEM_CODE','物料编码','物料自动编码',16,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','103','2026-07-16 16:17:45',0x00),(3,'MD_VENDOR_CODE','供应商编码','供应商自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(4,'MD_CLIENT_CODE','客户编码','客户自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(5,'MD_WORKSTATION_CODE','工作站编码','工作站自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(6,'MD_WORKSHOP_CODE','车间编码','车间自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(7,'TM_TOOL_TYPE_CODE','工具类型编码','工具类型自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(10,'WM_WAREHOUSE_CODE','仓库编码','仓库自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(11,'WM_LOCATION_CODE','库区编码','库区自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(12,'WM_AREA_CODE','库位编码','库位自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(13,'WM_ARRIVAL_NOTICE_CODE','到货通知单编码','到货通知单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(14,'WM_ITEM_RECEIPT_CODE','采购入库单编码','采购入库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(15,'WM_RETURN_VENDOR_CODE','采购退货单编码','采购退货单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(16,'WM_RETURN_ISSUE_CODE','生产退料单编码','生产退料单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(17,'WM_SN_CODE','SN码','SN码自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(18,'WM_PACKAGE_CODE','装箱单编码','装箱单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(19,'WM_BATCH_CODE','批次编码','批次自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(20,'WM_PRODUCT_SALES_CODE','销售出库单编码','销售出库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(21,'WM_MISC_RECEIPT_CODE','杂项入库单编码','杂项入库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(22,'WM_MISC_ISSUE_CODE','杂项出库单编码','杂项出库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(23,'WM_STOCK_TAKING_PLAN_CODE','盘点方案编码','盘点方案自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(24,'WM_STOCK_TAKING_CODE','盘点任务编码','盘点任务自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(25,'TRANSFER_CODE','转移调拨单编码','转移调拨单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(26,'WM_OUTSOURCE_ISSUE_CODE','外协发料单编码','外协发料单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(27,'WM_OUTSOURCE_RECEIPT_CODE','外协入库单编码','外协入库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(28,'WM_SALES_NOTICE_CODE','发货通知单编码','发货通知单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(29,'WM_RETURN_SALES_CODE','销售退货单编码','销售退货单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(30,'PRO_WORK_ORDER_CODE','生产工单编码','生产工单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(31,'PRO_TASK_CODE','生产任务编码','生产任务自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(32,'PRO_ROUTE_CODE','工艺路线编码','工艺路线自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(33,'PRO_FEEDBACK_CODE','生产报工单编码','生产报工单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(40,'QC_IQC_CODE','来料检验单编码','来料检验单自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(41,'QC_IPQC_CODE','过程检验单编码','过程检验单自动编码',16,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(42,'QC_OQC_CODE','出货检验单编码','出货检验单自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(43,'QC_RQC_CODE','退货检验单编码','退货检验单自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(44,'QC_DEFECT_CODE','缺陷类型编码','缺陷类型自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(45,'QC_INDICATOR_RESULT_CODE','样品检验结果编码','样品检验结果自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(50,'DV_MACHINERY_CODE','设备编码','设备自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(51,'DV_MACHINERY_TYPE_CODE','设备类型编码','设备类型自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(52,'DV_SUBJECT_CODE','点检保养项目编码','点检保养项目自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(53,'DV_REPAIR_CODE','维修单编码','维修单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(60,'PD_DOCUMENT_AUDIT_CODE','资料审核单编码','资料审核单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(61,'PD_ARCHIVE_CODE','归档台账编码','归档台账自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(62,'PD_PROCESS_PARAM_CODE','工艺参数编码','工艺参数自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(63,'PD_PROCESS_PARAM_TEMPLATE_CODE','工艺参数模板编码','工艺参数模板自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 16:06:38',0x00),(64,'WM_PRODUCT_ISSUE_CODE','领料单',NULL,15,0x01,'0',1,0,NULL,'103','2026-07-16 16:58:14','103','2026-07-16 16:59:03',0x00),(65,'MPS','生产主计划编码','生产主计划自动编码',14,0x01,'0',1,0,'','1','2026-07-17 05:17:30','1','2026-07-17 05:17:30',0x00),(66,'QC_INDICATOR_CODE','质检指标编码',NULL,12,0x01,'0',1,0,NULL,'1','2026-07-17 14:31:46','1','2026-07-17 14:32:23',0x00),(67,'QC_TEMPLATE_CODE','质检方案编码',NULL,11,0x01,'0',1,0,NULL,'1','2026-07-17 14:36:35','1','2026-07-17 14:37:05',0x00),(68,'SALESNOTICE_CODE','销售通知编码','销售通知自动编码',20,0x01,'0',1,0,'','admin','2026-07-17 07:36:53','admin','2026-07-17 07:36:53',0x00),(69,'CAL_TEAM_CODE','班组编码规则','班组编码规则',10,0x01,'0',1,0,NULL,'1','2026-07-31 09:57:04','1','2026-07-31 09:57:36',0x00),(70,'CAL_PLAN_CODE','排班计划',NULL,14,0x01,'0',1,0,NULL,'1','2026-07-31 09:59:32','1','2026-07-31 10:00:17',0x00);
INSERT INTO `mes_md_auto_code_rule` (`id`, `code`, `name`, `description`, `max_length`, `padded`, `padded_char`, `padded_method`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1,'MD_ITEM_TYPE_CODE','物料分类编码','物料分类自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(2,'MD_ITEM_CODE','物料编码','物料自动编码',16,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','103','2026-07-16 16:17:45',0x00),(3,'MD_VENDOR_CODE','供应商编码','供应商自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(4,'MD_CLIENT_CODE','客户编码','客户自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(5,'MD_WORKSTATION_CODE','工作站编码','工作站自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(6,'MD_WORKSHOP_CODE','车间编码','车间自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(7,'TM_TOOL_TYPE_CODE','工具类型编码','工具类型自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(10,'WM_WAREHOUSE_CODE','仓库编码','仓库自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(11,'WM_LOCATION_CODE','库区编码','库区自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(12,'WM_AREA_CODE','库位编码','库位自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(13,'WM_ARRIVAL_NOTICE_CODE','到货通知单编码','到货通知单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(14,'WM_ITEM_RECEIPT_CODE','采购入库单编码','采购入库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(15,'WM_RETURN_VENDOR_CODE','采购退货单编码','采购退货单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(16,'WM_RETURN_ISSUE_CODE','生产退料单编码','生产退料单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(17,'WM_SN_CODE','SN码','SN码自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(18,'WM_PACKAGE_CODE','装箱单编码','装箱单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(19,'WM_BATCH_CODE','批次编码','批次自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(20,'WM_PRODUCT_SALES_CODE','销售出库单编码','销售出库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(21,'WM_MISC_RECEIPT_CODE','杂项入库单编码','杂项入库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(22,'WM_MISC_ISSUE_CODE','杂项出库单编码','杂项出库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(23,'WM_STOCK_TAKING_PLAN_CODE','盘点方案编码','盘点方案自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(24,'WM_STOCK_TAKING_CODE','盘点任务编码','盘点任务自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(25,'TRANSFER_CODE','转移调拨单编码','转移调拨单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(26,'WM_OUTSOURCE_ISSUE_CODE','外协发料单编码','外协发料单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(27,'WM_OUTSOURCE_RECEIPT_CODE','外协入库单编码','外协入库单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(28,'WM_SALES_NOTICE_CODE','发货通知单编码','发货通知单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(29,'WM_RETURN_SALES_CODE','销售退货单编码','销售退货单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(30,'PRO_WORK_ORDER_CODE','生产工单编码','生产工单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(31,'PRO_TASK_CODE','生产任务编码','生产任务自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(32,'PRO_ROUTE_CODE','工艺路线编码','工艺路线自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(33,'PRO_FEEDBACK_CODE','生产报工单编码','生产报工单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(40,'QC_IQC_CODE','来料检验单编码','来料检验单自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(41,'QC_IPQC_CODE','过程检验单编码','过程检验单自动编码',16,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(42,'QC_OQC_CODE','出货检验单编码','出货检验单自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(43,'QC_RQC_CODE','退货检验单编码','退货检验单自动编码',15,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(44,'QC_DEFECT_CODE','缺陷类型编码','缺陷类型自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(45,'QC_INDICATOR_RESULT_CODE','样品检验结果编码','样品检验结果自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(50,'DV_MACHINERY_CODE','设备编号','设备自动编号',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(51,'DV_MACHINERY_TYPE_CODE','设备类型编码','设备类型自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(52,'DV_SUBJECT_CODE','点检保养项目编码','点检保养项目自动编码',6,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:06:12',0x00),(53,'DV_REPAIR_CODE','维修单编码','维修单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(60,'PD_DOCUMENT_AUDIT_CODE','资料审核单编码','资料审核单自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(61,'PD_ARCHIVE_CODE','归档台账编码','归档台账自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(62,'PD_PROCESS_PARAM_CODE','工艺参数编码','工艺参数自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 08:15:29',0x00),(63,'PD_PROCESS_PARAM_TEMPLATE_CODE','工艺参数模板编码','工艺参数模板自动编码',14,0x01,'0',1,0,NULL,'1','2026-07-16 08:06:12','1','2026-07-16 16:06:38',0x00),(64,'WM_PRODUCT_ISSUE_CODE','领料单',NULL,15,0x01,'0',1,0,NULL,'103','2026-07-16 16:58:14','103','2026-07-16 16:59:03',0x00),(65,'MPS','生产主计划编码','生产主计划自动编码',14,0x01,'0',1,0,'','1','2026-07-17 05:17:30','1','2026-07-17 05:17:30',0x00),(66,'QC_INDICATOR_CODE','质检指标编码',NULL,12,0x01,'0',1,0,NULL,'1','2026-07-17 14:31:46','1','2026-07-17 14:32:23',0x00),(67,'QC_TEMPLATE_CODE','质检方案编码',NULL,11,0x01,'0',1,0,NULL,'1','2026-07-17 14:36:35','1','2026-07-17 14:37:05',0x00),(68,'SALESNOTICE_CODE','销售通知编码','销售通知自动编码',20,0x01,'0',1,0,'','admin','2026-07-17 07:36:53','admin','2026-07-17 07:36:53',0x00),(69,'CAL_TEAM_CODE','班组编码规则','班组编码规则',10,0x01,'0',1,0,NULL,'1','2026-07-31 09:57:04','1','2026-07-31 09:57:36',0x00),(70,'CAL_PLAN_CODE','排班计划',NULL,14,0x01,'0',1,0,NULL,'1','2026-07-31 09:59:32','1','2026-07-31 10:00:17',0x00);
-- ============ è¡¨ï¼šmes_md_auto_code_part(导出 145 è¡Œï¼‰ ============
DROP TABLE IF EXISTS `mes_md_auto_code_part`;
@@ -1797,6 +1797,8 @@
  `issue_type` tinyint NOT NULL DEFAULT '0' COMMENT '问题类型: 0-未判定 1-一般问题 2-维修问题 3-退货问题',
  `issue_category` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '问题分类',
  `issue_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '问题描述',
  `feedback_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '客户诉求反馈描述',
  `result_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '处理结果描述',
  `severity_level` tinyint DEFAULT '0' COMMENT '严重程度: 0-轻微 1-一般 2-严重 3-致命',
  `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态: 0-草稿 10-处理中 30-待退货 40-待退款/换货 50-已完结 60-已关闭 99-已取消',
  `visit_status` tinyint NOT NULL DEFAULT '0' COMMENT '回访状态: 0-待回访 1-已回访通过',
@@ -1854,6 +1856,26 @@
  KEY `idx_ticket_id` (`ticket_id`) USING BTREE,
  KEY `idx_item_id` (`item_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='售后工单明细行表';
-- ============ è¡¨ï¼šafter_sale_ticket_flow(仅 DDL,无数据) ============
DROP TABLE IF EXISTS `after_sale_ticket_flow`;
CREATE TABLE `after_sale_ticket_flow` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '流转记录编号',
  `ticket_id` bigint NOT NULL COMMENT '售后工单编号,关联 after_sale_ticket.id',
  `action` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作编码:CREATE/UPDATE/ASSIGN/CLASSIFY/RESOLVE/ARCHIVE/CANCEL/VISIT',
  `action_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作名称,如:创建工单、分配处理人',
  `from_status` int DEFAULT NULL COMMENT '变更前状态,关联 AfterSaleTicketStatusEnum',
  `to_status` int DEFAULT NULL COMMENT '变更后状态,关联 AfterSaleTicketStatusEnum',
  `content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '流转内容/说明',
  `operator_id` bigint DEFAULT NULL COMMENT '操作人用户编号',
  `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `idx_ticket_id` (`ticket_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='售后工单流转记录';
-- ============ è¡¨ï¼šafter_sale_visit(仅 DDL,无数据) ============
DROP TABLE IF EXISTS `after_sale_visit`;
@@ -4877,7 +4899,7 @@
DROP TABLE IF EXISTS `mes_dv_machinery`;
CREATE TABLE `mes_dv_machinery` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
  `code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备编码',
  `code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备编号',
  `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备名称',
  `brand` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '品牌',
  `specification` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '规格型号',
@@ -6836,6 +6858,7 @@
  `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '名称',
  `purchase_order_id` bigint DEFAULT NULL COMMENT '采购订单编号',
  `purchase_order_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购订单编码(冗余字段)',
  `contract_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购合同号(创建时从采购订单快照,换货到货沿用退货单合同号)',
  `vendor_id` bigint DEFAULT NULL COMMENT '供应商 ID',
  `arrival_date` datetime DEFAULT NULL COMMENT '预计到货日期',
  `status` int DEFAULT NULL COMMENT '状态',
@@ -10180,3 +10203,221 @@
       (1, 5393, '1', NOW(), '1', NOW(), b'0');
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- è¡¥å……(2026-11-05):离职申请增强
-- äº¤æŽ¥å‘˜å·¥/用户与业务数据转移、离职审批分类、到期自动离职 Job
-- ============================================================
-- å¯é‡å¤æ‰§è¡Œï¼›ä¸åŒ…含 tenant_id。
-- 1. ç”¨æˆ·æ•°æ®äº¤æŽ¥æ˜ å°„表(补齐员工ID维度)
CREATE TABLE IF NOT EXISTS `hrm_user_handover` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '映射ID',
    `from_employee_id` bigint DEFAULT NULL COMMENT '离职员工ID',
    `from_user_id` bigint NOT NULL COMMENT '离职用户ID',
    `to_employee_id` bigint DEFAULT NULL COMMENT '交接目标员工ID',
    `to_user_id` bigint NOT NULL COMMENT '交接目标用户ID',
    `resignation_id` bigint NOT NULL COMMENT '关联的离职申请ID',
    `handover_status` tinyint NOT NULL DEFAULT 0 COMMENT '交接状态:0-进行中,20-已完成',
    `handover_time` datetime DEFAULT NULL COMMENT '交接完成时间',
    `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`) USING BTREE,
    UNIQUE KEY `uk_from_to_resignation` (`from_user_id`, `to_user_id`, `resignation_id`),
    KEY `idx_from_user_id` (`from_user_id`),
    KEY `idx_to_user_id` (`to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HRM用户数据交接映射表';
-- 1.1 å·²å­˜åœ¨çš„æ—§è¡¨è¡¥é½å‘˜å·¥ID列
SET @target_db = DATABASE();
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_user_handover ADD COLUMN from_employee_id BIGINT NULL COMMENT ''离职员工ID'' AFTER id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_user_handover' AND column_name = 'from_employee_id');
PREPARE hrm_stmt FROM @ddl;
EXECUTE hrm_stmt;
DEALLOCATE PREPARE hrm_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_user_handover ADD COLUMN to_employee_id BIGINT NULL COMMENT ''交接目标员工ID'' AFTER from_user_id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_user_handover' AND column_name = 'to_employee_id');
PREPARE hrm_stmt FROM @ddl;
EXECUTE hrm_stmt;
DEALLOCATE PREPARE hrm_stmt;
-- 2. ç¦»èŒå®¡æ‰¹åˆ†ç±»ï¼ˆå‰ç«¯æäº¤çš„ processDefinitionKey åŽ†å²å€¼ä¸ºè¯¥åˆ†ç±»ç¼–ç ï¼‰
SET @category_id = (SELECT COALESCE(MAX(id), 0) + 1 FROM bpm_category);
INSERT INTO `bpm_category` (`id`, `name`, `code`, `description`, `status`, `sort`, `creator`, `updater`)
SELECT @category_id, '离职审批', 'hrm_resignation_approve', '离职申请审批流程', 0, 0, '1', '1'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM bpm_category WHERE code = 'hrm_resignation_approve' AND deleted = b'0');
-- 3. åˆ°æœŸè‡ªåŠ¨ç¦»èŒ Job(每天 00:10 æ‰§è¡Œï¼‰
INSERT INTO `infra_job` (`name`, `status`, `handler_name`, `handler_param`, `cron_expression`,
                         `retry_count`, `retry_interval`, `monitor_timeout`, `creator`, `updater`)
SELECT '离职到期自动办理', 1, 'hrmResignationAutoLeaveJob', '', '0 10 0 * * ?', 0, 0, 0, '1', '1'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM infra_job WHERE handler_name = 'hrmResignationAutoLeaveJob' AND deleted = b'0');
-- 4. ç¦»èŒç”³è¯·äº¤æŽ¥å­—段(员工ID、用户数据交接状态)
SET @target_db = DATABASE();
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_resignation_application ADD COLUMN handover_employee_id BIGINT NULL COMMENT ''交接员工ID''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_resignation_application' AND column_name = 'handover_employee_id');
PREPARE handover_stmt FROM @ddl;
EXECUTE handover_stmt;
DEALLOCATE PREPARE handover_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_resignation_application ADD COLUMN handover_data_status TINYINT NOT NULL DEFAULT 0 COMMENT ''用户数据交接状态:0未开始 10进行中 20已完成''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_resignation_application' AND column_name = 'handover_data_status');
PREPARE handover_stmt FROM @ddl;
EXECUTE handover_stmt;
DEALLOCATE PREPARE handover_stmt;
-- ============================================================
-- è¡¥å……(2026-11-05):人力资源 - äººäº‹ç»Ÿè®¡èœå•
-- é¡µé¢ï¼š/hrm/statistics(工龄、年龄、学历、性别、部门工龄、工龄排行)
-- ============================================================
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name,
                         status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
VALUES (1063300, '人事统计', '', 2, 0, 6001, 'statistics', 'ep:data-analysis',
        'hrm/statistics/index', 'HrmHumanStatistics', 0, b'1', b'0', b'1', '1', NOW(), '1', NOW(), b'0')
ON DUPLICATE KEY UPDATE name = VALUES(name), parent_id = VALUES(parent_id), path = VALUES(path),
                        component = VALUES(component), component_name = VALUES(component_name),
                        status = VALUES(status), visible = VALUES(visible), deleted = b'0';
-- å¯è§è§’色与「员工管理」保持一致(幂等,重复执行不产生重复行)
INSERT INTO system_role_menu (role_id, menu_id, creator, create_time, updater, update_time, deleted)
SELECT rm.role_id, 1063300, '1', NOW(), '1', NOW(), b'0'
FROM system_role_menu rm
LEFT JOIN system_role_menu ex
       ON ex.role_id = rm.role_id AND ex.menu_id = 1063300 AND ex.deleted = b'0'
WHERE rm.menu_id = 6006 AND rm.deleted = b'0' AND ex.id IS NULL;
-- ============================================================
-- è¡¥å……(2026-11-05):财务 - é”€å”®/采购闭环
-- æ¥ç¥¨ç»‘定采购订单+入库单;开票绑定销售订单+出库单;
-- æ”¶æ¬¾èŠ‚ç‚¹ï¼›å›žæ¬¾æ ¸é”€æ˜Žç»†ï¼›å‡­è¯æ¥æºå•æ®ã€‚
-- ============================================================
-- 1. æ¥ç¥¨ï¼šç»‘定 MES é‡‡è´­å…¥åº“单
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE erp_purchase_invoice ADD COLUMN item_receipt_id BIGINT NULL COMMENT ''关联 MES é‡‡è´­å…¥åº“单ID'' AFTER arrival_notice_id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'erp_purchase_invoice' AND column_name = 'item_receipt_id');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE erp_purchase_invoice ADD COLUMN item_receipt_code VARCHAR(64) NULL COMMENT ''关联 MES é‡‡è´­å…¥åº“单号'' AFTER item_receipt_id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'erp_purchase_invoice' AND column_name = 'item_receipt_code');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
-- 2. å¼€ç¥¨ï¼šå‡ºåº“单号快照 + å‘票录入状态(开票申请 -> å½•入发票)
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_invoice ADD COLUMN sale_out_code VARCHAR(64) NULL COMMENT ''销售出库单号快照'' AFTER sale_out_id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_invoice' AND column_name = 'sale_out_code');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_invoice ADD COLUMN entry_status TINYINT NOT NULL DEFAULT 0 COMMENT ''发票录入状态:0待录入 10已录入'' AFTER sale_out_code',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_invoice' AND column_name = 'entry_status');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_invoice ADD COLUMN entry_user_id BIGINT NULL COMMENT ''发票录入人'' AFTER entry_status',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_invoice' AND column_name = 'entry_user_id');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_invoice ADD COLUMN entry_time DATETIME NULL COMMENT ''发票录入时间'' AFTER entry_user_id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_invoice' AND column_name = 'entry_time');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
-- 3. å›žæ¬¾è®¡åˆ’:收款阶段/来源开票/来源订单/实际回款
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_receivable_plan ADD COLUMN stage TINYINT NULL COMMENT ''收款阶段:1合同款 2预付款 3到货款 4验收款 5质保金''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_receivable_plan' AND column_name = 'stage');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_receivable_plan ADD COLUMN invoice_id BIGINT NULL COMMENT ''来源开票单编号''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_receivable_plan' AND column_name = 'invoice_id');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_receivable_plan ADD COLUMN sale_order_id BIGINT NULL COMMENT ''来源销售订单编号''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_receivable_plan' AND column_name = 'sale_order_id');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_receivable_plan ADD COLUMN actual_time DATETIME NULL COMMENT ''实际回款时间''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_receivable_plan' AND column_name = 'actual_time');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_receivable_plan ADD COLUMN receipted_price DECIMAL(24,6) NOT NULL DEFAULT 0 COMMENT ''已回款金额''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_receivable_plan' AND column_name = 'receipted_price');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE crm_receivable_plan ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT ''状态:0未回款 10部分回款 20已回清''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'crm_receivable_plan' AND column_name = 'status');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
-- 4. å›žæ¬¾æ ¸é”€æ˜Žç»†
CREATE TABLE IF NOT EXISTS `crm_receivable_item` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
  `receivable_id` bigint NOT NULL COMMENT '回款单ID',
  `biz_type` int NOT NULL COMMENT '核销单据类型:20销售订单 21销售出库 23开票',
  `biz_id` bigint NOT NULL COMMENT '核销单据ID',
  `biz_no` varchar(64) DEFAULT NULL COMMENT '核销单据号',
  `total_price` decimal(24,6) NOT NULL DEFAULT '0.000000' COMMENT '单据金额',
  `receipted_price` decimal(24,6) NOT NULL DEFAULT '0.000000' COMMENT '本次核销前已回款金额',
  `receipt_price` decimal(24,6) NOT NULL DEFAULT '0.000000' COMMENT '本次核销金额',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  `creator` varchar(64) DEFAULT '' COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) DEFAULT '' COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_receivable_id` (`receivable_id`),
  KEY `idx_biz` (`biz_type`,`biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='CRM å›žæ¬¾æ ¸é”€æ˜Žç»†';
-- 5. è´¢åŠ¡å‡­è¯ï¼šæ¥æºå•æ®
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE fin_voucher ADD COLUMN source_type VARCHAR(32) NULL COMMENT ''来源单据类型:CRM_INVOICE/CRM_RECEIVABLE/ERP_PURCHASE_INVOICE/ERP_PAYMENT''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fin_voucher' AND column_name = 'source_type');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE fin_voucher ADD COLUMN source_id BIGINT NULL COMMENT ''来源单据编号''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fin_voucher' AND column_name = 'source_id');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE fin_voucher ADD COLUMN source_no VARCHAR(64) NULL COMMENT ''来源单据号''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fin_voucher' AND column_name = 'source_no');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
-- 6. MES å‡ºåº“/入库行金额快照(开票、对账金额口径)
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE mes_wm_product_sales_line ADD COLUMN price DECIMAL(24,6) NULL COMMENT ''单价(销售订单行快照)''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'mes_wm_product_sales_line' AND column_name = 'price');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE mes_wm_product_sales_line ADD COLUMN total_price DECIMAL(24,6) NULL COMMENT ''金额(数量×单价)''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'mes_wm_product_sales_line' AND column_name = 'total_price');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE mes_wm_item_receipt_line ADD COLUMN price DECIMAL(24,6) NULL COMMENT ''单价(采购订单行快照)''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'mes_wm_item_receipt_line' AND column_name = 'price');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE mes_wm_item_receipt_line ADD COLUMN total_price DECIMAL(24,6) NULL COMMENT ''金额(数量×单价)''',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'mes_wm_item_receipt_line' AND column_name = 'total_price');
PREPARE fin_stmt FROM @ddl; EXECUTE fin_stmt; DEALLOCATE PREPARE fin_stmt;
-- 7. å­—典:收款阶段
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1065416, '回款阶段', 'crm_payment_stage', 0, '开票录入时按销售订单拆分合同款、预付款、到货款、验收款', '1', NOW(), '1', NOW(), b'0')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `status` = VALUES(`status`), `remark` = VALUES(`remark`), `deleted` = b'0';
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES (1065501, 1, '合同款', '1', 'crm_payment_stage', 0, 'primary', '', '', '1', NOW(), '1', NOW(), b'0'),
       (1065502, 2, '预付款', '2', 'crm_payment_stage', 0, 'success', '', '', '1', NOW(), '1', NOW(), b'0'),
       (1065503, 3, '到货款', '3', 'crm_payment_stage', 0, 'warning', '', '', '1', NOW(), '1', NOW(), b'0'),
       (1065504, 4, '验收款', '4', 'crm_payment_stage', 0, 'info', '', '', '1', NOW(), '1', NOW(), b'0'),
       (1065505, 5, '质保金', '5', 'crm_payment_stage', 0, 'danger', '', '', '1', NOW(), '1', NOW(), b'0')
ON DUPLICATE KEY UPDATE `sort` = VALUES(`sort`), `label` = VALUES(`label`), `value` = VALUES(`value`),
                        `status` = VALUES(`status`), `color_type` = VALUES(`color_type`), `deleted` = b'0';
sql/mysql/20261105_arrival_notice_contract_no.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
-- =============================================
-- åˆ°è´§é€šçŸ¥å•补充采购合同号
-- èƒŒæ™¯ï¼šé‡‡è´­é€€è´§é€‰æ‹©ã€Œæ¢è´§ã€åŽï¼Œç³»ç»Ÿé‡æ–°ç”Ÿæˆåˆ°è´§é€šçŸ¥å•并重新质检,
--       éœ€è¦åœ¨åˆ°è´§é€šçŸ¥å•上重新关联原采购合同号,便于采购/财务追溯。
-- ç›®æ ‡åº“:mom-ntsh(MySQL 8.4)
-- è¯´æ˜Žï¼šè„šæœ¬å¯é‡å¤æ‰§è¡Œï¼›ä¸åŒ…å« tenant_id(本项目不使用多租户)
-- =============================================
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'mes_wm_arrival_notice' AND column_name = 'contract_no');
SET @sql = IF(@exists = 0,
    'ALTER TABLE mes_wm_arrival_notice ADD COLUMN contract_no VARCHAR(64) NULL COMMENT ''采购合同号(创建时从采购订单快照,换货到货沿用退货单合同号)'' AFTER purchase_order_code',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- åŽ†å²æ•°æ®å›žå¡«ï¼šä»Žé‡‡è´­è®¢å•è¡¥åˆåŒå·
UPDATE mes_wm_arrival_notice n
JOIN erp_purchase_order o ON o.id = n.purchase_order_id AND o.deleted = 0
SET n.contract_no = o.contract_no
WHERE (n.contract_no IS NULL OR n.contract_no = '')
  AND n.purchase_order_id IS NOT NULL;
sql/mysql/20261105_hrm_resignation_flow.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
-- ç¦»èŒç”³è¯·å¢žå¼ºï¼šäº¤æŽ¥å‘˜å·¥/用户与用户数据转移、离职审批分类、到期自动离职 Job
-- å¯é‡å¤æ‰§è¡Œï¼›ä¸åŒ…含 tenant_id。
-- 1. ç”¨æˆ·æ•°æ®äº¤æŽ¥æ˜ å°„表(补齐员工ID维度)
CREATE TABLE IF NOT EXISTS `hrm_user_handover` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '映射ID',
    `from_employee_id` bigint DEFAULT NULL COMMENT '离职员工ID',
    `from_user_id` bigint NOT NULL COMMENT '离职用户ID',
    `to_employee_id` bigint DEFAULT NULL COMMENT '交接目标员工ID',
    `to_user_id` bigint NOT NULL COMMENT '交接目标用户ID',
    `resignation_id` bigint NOT NULL COMMENT '关联的离职申请ID',
    `handover_status` tinyint NOT NULL DEFAULT 0 COMMENT '交接状态:0-进行中,20-已完成',
    `handover_time` datetime DEFAULT NULL COMMENT '交接完成时间',
    `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`) USING BTREE,
    UNIQUE KEY `uk_from_to_resignation` (`from_user_id`, `to_user_id`, `resignation_id`),
    KEY `idx_from_user_id` (`from_user_id`),
    KEY `idx_to_user_id` (`to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HRM用户数据交接映射表';
-- 1.1 å·²å­˜åœ¨çš„æ—§è¡¨è¡¥é½å‘˜å·¥ID列
SET @target_db = DATABASE();
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_user_handover ADD COLUMN from_employee_id BIGINT NULL COMMENT ''离职员工ID'' AFTER id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_user_handover' AND column_name = 'from_employee_id');
PREPARE hrm_stmt FROM @ddl;
EXECUTE hrm_stmt;
DEALLOCATE PREPARE hrm_stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
  'ALTER TABLE hrm_user_handover ADD COLUMN to_employee_id BIGINT NULL COMMENT ''交接目标员工ID'' AFTER from_user_id',
  'SELECT 1') FROM information_schema.columns WHERE table_schema = @target_db AND table_name = 'hrm_user_handover' AND column_name = 'to_employee_id');
PREPARE hrm_stmt FROM @ddl;
EXECUTE hrm_stmt;
DEALLOCATE PREPARE hrm_stmt;
-- 2. ç¦»èŒå®¡æ‰¹åˆ†ç±»ï¼ˆå‰ç«¯æäº¤çš„ processDefinitionKey åŽ†å²å€¼ä¸ºè¯¥åˆ†ç±»ç¼–ç ï¼‰
SET @category_id = (SELECT COALESCE(MAX(id), 0) + 1 FROM bpm_category);
INSERT INTO `bpm_category` (`id`, `name`, `code`, `description`, `status`, `sort`, `creator`, `updater`)
SELECT @category_id, '离职审批', 'hrm_resignation_approve', '离职申请审批流程', 0, 0, '1', '1'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM bpm_category WHERE code = 'hrm_resignation_approve' AND deleted = b'0');
-- 3. åˆ°æœŸè‡ªåŠ¨ç¦»èŒ Job(每天 00:10 æ‰§è¡Œï¼‰
INSERT INTO `infra_job` (`name`, `status`, `handler_name`, `handler_param`, `cron_expression`,
                         `retry_count`, `retry_interval`, `monitor_timeout`, `creator`, `updater`)
SELECT '离职到期自动办理', 1, 'hrmResignationAutoLeaveJob', '', '0 10 0 * * ?', 0, 0, 0, '1', '1'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM infra_job WHERE handler_name = 'hrmResignationAutoLeaveJob' AND deleted = b'0');
sql/mysql/aftersales_ticket_flow_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
-- ============================================================
-- å”®åŽå·¥å•:流转记录表 + ä¸‹çº¿ã€Œé€€è´§ç”³è¯·ã€èœå•
-- æ—¥æœŸï¼š2026-11-05
-- è¯´æ˜Žï¼šä»“储物流已提供退货操作,售后模块不再保留「退货申请」页面
-- ============================================================
-- 1. å”®åŽå·¥å•流转记录表
CREATE TABLE IF NOT EXISTS `after_sale_ticket_flow` (
    `id`          bigint       NOT NULL AUTO_INCREMENT COMMENT '流转记录编号',
    `ticket_id`   bigint       NOT NULL COMMENT '售后工单编号,关联 after_sale_ticket.id',
    `action`      varchar(32)  NOT NULL COMMENT '动作编码:CREATE/UPDATE/ASSIGN/CLASSIFY/RESOLVE/ARCHIVE/CANCEL/VISIT',
    `action_name` varchar(64)  NOT NULL COMMENT '动作名称,如:创建工单、分配处理人',
    `from_status` int                   DEFAULT NULL COMMENT '变更前状态,关联 AfterSaleTicketStatusEnum',
    `to_status`   int                   DEFAULT NULL COMMENT '变更后状态,关联 AfterSaleTicketStatusEnum',
    `content`     varchar(1000)         DEFAULT NULL COMMENT '流转内容/说明',
    `operator_id` bigint                DEFAULT NULL COMMENT '操作人用户编号',
    `creator`     varchar(64)           DEFAULT '' COMMENT '创建者',
    `create_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`     varchar(64)           DEFAULT '' COMMENT '更新者',
    `update_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`     bit(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    KEY `idx_ticket_id` (`ticket_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci COMMENT ='售后工单流转记录';
-- 1.1 è¡¥é½å·¥å•表缺失的客户诉求/处理结果描述列(MySQL ä¸æ”¯æŒ ADD COLUMN IF NOT EXISTS)
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'after_sale_ticket'
                 AND column_name = 'feedback_description');
SET @sql = IF(@exists = 0,
              'ALTER TABLE `after_sale_ticket` ADD COLUMN `feedback_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT ''客户诉求反馈描述'' AFTER `issue_description`',
              'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'after_sale_ticket'
                 AND column_name = 'result_description');
SET @sql = IF(@exists = 0,
              'ALTER TABLE `after_sale_ticket` ADD COLUMN `result_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT ''处理结果描述'' AFTER `feedback_description`',
              'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. ä¸‹çº¿ã€Œé€€è´§ç”³è¯·ã€èœå•(含按钮权限)
--    é€€è´§ç”³è¯·èœå• id = 602589,父菜单 = å”®åŽç®¡ç† 602587;
--    å¦‚其他环境 id ä¸åŒï¼ŒæŒ‰ path = '/aftersales/return/index' å®šä½åŽæ›¿æ¢ã€‚
DELETE FROM `system_role_menu`
WHERE `menu_id` IN (SELECT `id` FROM `system_menu`
                    WHERE `id` = 602589 OR `parent_id` = 602589);
DELETE FROM `system_menu`
WHERE `parent_id` = 602589;
DELETE FROM `system_menu`
WHERE `id` = 602589
  AND `component` LIKE '%aftersales/return/index%';
sql/mysql/hrm_user_handover_init.sql
@@ -1,7 +1,9 @@
-- HRM用户数据交接映射表
CREATE TABLE IF NOT EXISTS `hrm_user_handover` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '映射ID',
    `from_employee_id` bigint DEFAULT NULL COMMENT '离职员工ID',
    `from_user_id` bigint NOT NULL COMMENT '离职用户ID',
    `to_employee_id` bigint DEFAULT NULL COMMENT '交接目标员工ID',
    `to_user_id` bigint NOT NULL COMMENT '交接目标用户ID',
    `resignation_id` bigint NOT NULL COMMENT '关联的离职申请ID',
    `handover_status` tinyint NOT NULL DEFAULT 0 COMMENT '交接状态:0-进行中,20-已完成',
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/ExcelRequired.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.framework.excel.core.handler;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
 * Excel å¿…填字段标记
 *
 * <p>标注在导入 VO çš„字段上,导出「模板」类 Excel æ—¶ï¼Œ
 * {@link RequiredHeaderWriteHandler} ä¼šæŠŠå¯¹åº”表头标红并加上星号,提示用户该列必填。
 *
 * @author è¶…级管理员
 */
@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelRequired {
}
yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/RequiredHeaderWriteHandler.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.framework.excel.core.handler;
import cn.idev.excel.metadata.Head;
import cn.idev.excel.write.handler.CellWriteHandler;
import cn.idev.excel.write.handler.context.CellWriteHandlerContext;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Workbook;
import java.lang.reflect.Field;
/**
 * å¿…填表头处理器
 *
 * <p>导出模板时,把标注了 {@link ExcelRequired} çš„字段对应的表头文字标红,并在前面加一个星号,
 * è®©ç”¨æˆ·ä¸€çœ¼çœ‹å‡ºå“ªäº›åˆ—必须填写。
 *
 * @author è¶…级管理员
 */
public class RequiredHeaderWriteHandler implements CellWriteHandler {
    private static final String REQUIRED_MARK = "*";
    @Override
    public void afterCellDispose(CellWriteHandlerContext context) {
        // 1. åªå¤„理表头单元格
        if (!Boolean.TRUE.equals(context.getHead())) {
            return;
        }
        Head head = context.getHeadData();
        Cell cell = context.getCell();
        if (head == null || head.getField() == null || cell == null) {
            return;
        }
        // 2. åªå¤„理标注了 @ExcelRequired çš„字段
        Field field = head.getField();
        if (!field.isAnnotationPresent(ExcelRequired.class)) {
            return;
        }
        // 3. å¤šçº§è¡¨å¤´æ—¶åªæ ‡è®°æœ€åŽä¸€è¡Œï¼Œé¿å…çˆ¶çº§è¡¨å¤´é‡å¤åŠ æ˜Ÿå·
        int lastHeadRowIndex = head.getHeadNameList() == null ? 0 : head.getHeadNameList().size() - 1;
        if (context.getRelativeRowIndex() != null && context.getRelativeRowIndex() < lastHeadRowIndex) {
            return;
        }
        // 4. è¡¨å¤´åŠ æ˜Ÿå·
        String text = cell.getStringCellValue();
        if (text != null && !text.startsWith(REQUIRED_MARK)) {
            cell.setCellValue(REQUIRED_MARK + text);
        }
        // 5. å­—体标红,并保留原有表头样式的其它属性
        Workbook workbook = cell.getSheet().getWorkbook();
        CellStyle oldStyle = cell.getCellStyle();
        CellStyle style = workbook.createCellStyle();
        style.cloneStyleFrom(oldStyle);
        Font oldFont = workbook.getFontAt(oldStyle.getFontIndex());
        Font font = workbook.createFont();
        font.setFontName(oldFont.getFontName());
        font.setFontHeightInPoints(oldFont.getFontHeightInPoints());
        font.setBold(oldFont.getBold());
        font.setItalic(oldFont.getItalic());
        font.setColor(IndexedColors.RED.getIndex());
        style.setFont(font);
        cell.setCellStyle(style);
    }
}
yudao-module-aftersales-api/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApi.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleReturnRespDTO;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleTicketRespDTO;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleWarehouseReturnCreateReqDTO;
import java.math.BigDecimal;
@@ -38,6 +39,13 @@
    CommonResult<Boolean> onRefundCompleted(Long returnId, BigDecimal actualRefundPrice);
    /**
     * ä»“储物流创建销售退货单后回调,用于在关联售后工单上留痕
     *
     * @param reqDTO é€€è´§å•信息(销售订单编号、退货单号、退货原因等)
     */
    CommonResult<Boolean> onWarehouseReturnCreated(AfterSaleWarehouseReturnCreateReqDTO reqDTO);
    /**
     * æŸ¥è¯¢å”®åŽå·¥å•(供 MES/ERP æŸ¥è¯¢å…³è”信息)
     *
     * @param id å·¥å•编号
yudao-module-aftersales-api/src/main/java/cn/iocoder/yudao/module/aftersales/api/dto/AfterSaleWarehouseReturnCreateReqDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.aftersales.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
 * ä»“储物流创建销售退货单后同步给售后工单的 Request DTO
 */
@Schema(description = "仓储物流销售退货单创建通知 Request DTO")
@Data
public class AfterSaleWarehouseReturnCreateReqDTO {
    @Schema(description = "销售订单编号,用于匹配关联的售后工单", example = "1")
    private Long saleOrderId;
    @Schema(description = "MES é”€å”®é€€è´§å•编号", example = "1")
    private Long returnSalesId;
    @Schema(description = "MES é”€å”®é€€è´§å•号", example = "XSTH202611050001")
    private String returnSalesCode;
    @Schema(description = "退货原因", example = "客户反馈外观破损")
    private String returnReason;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApiImpl.java
@@ -4,6 +4,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleReturnRespDTO;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleTicketRespDTO;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleWarehouseReturnCreateReqDTO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.service.returnobj.AfterSaleReturnService;
@@ -45,6 +46,13 @@
    }
    @Override
    public CommonResult<Boolean> onWarehouseReturnCreated(AfterSaleWarehouseReturnCreateReqDTO reqDTO) {
        ticketService.onWarehouseReturnCreated(reqDTO.getSaleOrderId(), reqDTO.getReturnSalesId(),
                reqDTO.getReturnSalesCode(), reqDTO.getReturnReason());
        return success(true);
    }
    @Override
    public CommonResult<AfterSaleTicketRespDTO> getTicket(Long id) {
        AfterSaleTicketDO ticket = ticketService.getTicket(id);
        return success(BeanUtils.toBean(ticket, AfterSaleTicketRespDTO.class));
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java
@@ -5,11 +5,15 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketFlowRespVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketRespVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketFlowDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketItemDO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketFlowService;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketService;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
@@ -38,6 +42,8 @@
    @Resource
    private AfterSaleTicketService ticketService;
    @Resource
    private AfterSaleTicketFlowService ticketFlowService;
    @Resource
    private CrmCustomerApi customerApi;
@@ -80,8 +86,20 @@
        List<AfterSaleTicketRespVO> enrichedList = buildTicketDetailList(List.of(ticket));
        AfterSaleTicketRespVO respVO = enrichedList.get(0);
        respVO.setItems(BeanUtils.toBean(items, AfterSaleTicketRespVO.Item.class));
        respVO.setFeedbackAttachmentList(
                storageAttachmentApi.listAttachments("aftersales_after_sale_ticket_feedback", id));
        respVO.setResultAttachmentList(
                storageAttachmentApi.listAttachments("aftersales_after_sale_ticket_result", id));
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("aftersales_after_sale_ticket", id));
        respVO.setFlowList(buildFlowList(id));
        return success(respVO);
    }
    @GetMapping("/flow-list")
    @Operation(summary = "获得售后工单流转记录")
    @Parameter(name = "ticketId", description = "工单编号", required = true, example = "1")
    public CommonResult<List<AfterSaleTicketFlowRespVO>> getTicketFlowList(@RequestParam("ticketId") Long ticketId) {
        return success(buildFlowList(ticketId));
    }
    @GetMapping("/page")
@@ -90,15 +108,6 @@
        PageResult<AfterSaleTicketDO> pageResult = ticketService.getTicketPage(pageReqVO, getLoginUserId());
        List<AfterSaleTicketRespVO> list = buildTicketDetailList(pageResult.getList());
        return success(new PageResult<>(list, pageResult.getTotal()));
    }
    @PostMapping("/assign")
    @Operation(summary = "分配处理人")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> assignTicket(@RequestParam("id") Long id,
                                              @RequestParam("handlerUserId") Long handlerUserId) {
        ticketService.assignTicket(id, handlerUserId);
        return success(true);
    }
    @PostMapping("/classify")
@@ -136,7 +145,40 @@
        return success(true);
    }
    // ========== å†…部方法:批量拼装关联名称 ==========
    // ========== å†…部方法:流转记录 / æ‰¹é‡æ‹¼è£…关联名称 ==========
    private List<AfterSaleTicketFlowRespVO> buildFlowList(Long ticketId) {
        List<AfterSaleTicketFlowDO> flowList = ticketFlowService.getFlowList(ticketId);
        if (CollUtil.isEmpty(flowList)) {
            return Collections.emptyList();
        }
        Set<Long> operatorIds = convertSet(flowList, AfterSaleTicketFlowDO::getOperatorId);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(operatorIds);
        return BeanUtils.toBean(flowList, AfterSaleTicketFlowRespVO.class, vo -> {
            vo.setFromStatusName(getStatusName(vo.getFromStatus()));
            vo.setToStatusName(getStatusName(vo.getToStatus()));
            AdminUserRespDTO operator = userMap.get(vo.getOperatorId());
            if (operator != null) {
                vo.setOperatorName(operator.getNickname());
            } else if (vo.getOperatorId() != null) {
                vo.setOperatorName("用户(ID=" + vo.getOperatorId() + ")");
            } else {
                vo.setOperatorName("系统");
            }
        });
    }
    private String getStatusName(Integer status) {
        if (status == null) {
            return null;
        }
        for (AfterSaleTicketStatusEnum item : AfterSaleTicketStatusEnum.values()) {
            if (item.getStatus().equals(status)) {
                return item.getName();
            }
        }
        return "未知状态(ID=" + status + ")";
    }
    private List<AfterSaleTicketRespVO> buildTicketDetailList(List<AfterSaleTicketDO> ticketList) {
        if (CollUtil.isEmpty(ticketList)) {
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketFlowRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
 * å”®åŽå·¥å•流转记录 Response VO
 */
@Schema(description = "售后工单 - æµè½¬è®°å½• Response VO")
@Data
public class AfterSaleTicketFlowRespVO {
    @Schema(description = "流转记录编号", example = "1")
    private Long id;
    @Schema(description = "工单编号", example = "1")
    private Long ticketId;
    @Schema(description = "动作编码", example = "ASSIGN")
    private String action;
    @Schema(description = "动作名称", example = "分配处理人")
    private String actionName;
    @Schema(description = "变更前状态", example = "0")
    private Integer fromStatus;
    @Schema(description = "变更前状态名称", example = "草稿")
    private String fromStatusName;
    @Schema(description = "变更后状态", example = "10")
    private Integer toStatus;
    @Schema(description = "变更后状态名称", example = "处理中")
    private String toStatusName;
    @Schema(description = "流转内容", example = "分配处理人:张三")
    private String content;
    @Schema(description = "操作人用户编号", example = "1")
    private Long operatorId;
    @Schema(description = "操作人名称", example = "张三")
    private String operatorName;
    @Schema(description = "操作时间")
    private LocalDateTime createTime;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java
@@ -57,8 +57,14 @@
    @Schema(description = "客户诉求反馈描述")
    private String feedbackDescription;
    @Schema(description = "客户诉求附件列表")
    private List<StorageBlobRespDTO> feedbackAttachmentList;
    @Schema(description = "处理结果描述")
    private String resultDescription;
    @Schema(description = "处理结果附件列表")
    private List<StorageBlobRespDTO> resultAttachmentList;
    @Schema(description = "严重程度: 0-轻微 1-一般 2-严重 3-致命", example = "1")
    private Integer severityLevel;
@@ -126,9 +132,12 @@
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
    @Schema(description = "附件列表")
    @Schema(description = "通用附件列表(历史数据)")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "流转记录列表(按时间正序)")
    private List<AfterSaleTicketFlowRespVO> flowList;
    @Schema(description = "明细行列表")
    private List<Item> items;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSaveReqVO.java
@@ -53,8 +53,14 @@
    @Schema(description = "客户诉求反馈描述")
    private String feedbackDescription;
    @Schema(description = "客户诉求附件 blobId åˆ—表", example = "[1, 2]")
    private List<Long> feedbackBlobIds;
    @Schema(description = "处理结果描述")
    private String resultDescription;
    @Schema(description = "处理结果附件 blobId åˆ—表", example = "[3, 4]")
    private List<Long> resultBlobIds;
    @Schema(description = "严重程度: 0-轻微 1-一般 2-严重 3-致命", example = "1")
    private Integer severityLevel;
@@ -62,7 +68,7 @@
    @Schema(description = "备注", example = "客户要求尽快处理")
    private String remark;
    @Schema(description = "附件 blobId åˆ—表", example = "[1, 2, 3]")
    @Schema(description = "通用附件 blobId åˆ—表(历史字段,新数据请使用 feedbackBlobIds / resultBlobIds)", example = "[1, 2, 3]")
    private List<Long> blobIds;
    @Schema(description = "明细行列表")
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketFlowDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
 * å”®åŽå·¥å•流转记录 DO
 *
 * å·¥å•每一次状态推进、处理动作都会在这里留下一条记录,供详情页按时间线展示。
 */
@TableName("after_sale_ticket_flow")
@KeySequence("after_sale_ticket_flow_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AfterSaleTicketFlowDO extends BaseDO {
    /**
     * æµè½¬è®°å½•编号
     */
    @TableId
    private Long id;
    /**
     * å·¥å•编号,关联 after_sale_ticket.id
     */
    private Long ticketId;
    /**
     * åŠ¨ä½œç¼–ç ï¼šCREATE/UPDATE/ASSIGN/CLASSIFY/RESOLVE/ARCHIVE/CANCEL/VISIT
     */
    private String action;
    /**
     * åŠ¨ä½œåç§°ï¼Œå¦‚ã€Œåˆ›å»ºå·¥å•ã€ã€Œåˆ†é…å¤„ç†äººã€
     */
    private String actionName;
    /**
     * å˜æ›´å‰çŠ¶æ€ï¼Œå…³è” AfterSaleTicketStatusEnum
     */
    private Integer fromStatus;
    /**
     * å˜æ›´åŽçŠ¶æ€ï¼Œå…³è” AfterSaleTicketStatusEnum
     */
    private Integer toStatus;
    /**
     * æµè½¬å†…容/说明
     */
    private String content;
    /**
     * æ“ä½œäººç”¨æˆ·ç¼–号
     */
    private Long operatorId;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketFlowMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.aftersales.dal.mysql.ticket;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketFlowDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * å”®åŽå·¥å•流转记录 Mapper
 */
@Mapper
public interface AfterSaleTicketFlowMapper extends BaseMapperX<AfterSaleTicketFlowDO> {
    default List<AfterSaleTicketFlowDO> selectListByTicketId(Long ticketId) {
        return selectList(new LambdaQueryWrapperX<AfterSaleTicketFlowDO>()
                .eq(AfterSaleTicketFlowDO::getTicketId, ticketId)
                .orderByAsc(AfterSaleTicketFlowDO::getCreateTime)
                .orderByAsc(AfterSaleTicketFlowDO::getId));
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketMapper.java
@@ -52,6 +52,12 @@
        return selectOne(AfterSaleTicketDO::getSaleOrderId, saleOrderId);
    }
    default List<AfterSaleTicketDO> selectListBySaleOrderId(Long saleOrderId) {
        return selectList(new LambdaQueryWrapperX<AfterSaleTicketDO>()
                .eq(AfterSaleTicketDO::getSaleOrderId, saleOrderId)
                .orderByDesc(AfterSaleTicketDO::getId));
    }
    default Long selectCountByOwnerUserId(Long ownerUserId) {
        return selectCount(AfterSaleTicketDO::getOwnerUserId, ownerUserId);
    }
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java
@@ -20,6 +20,7 @@
    ErrorCode TICKET_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_022_000_007, "售后工单审批流程不存在或未激活,请检查 BPM é…ç½®");
    ErrorCode TICKET_STATUS_ERROR = new ErrorCode(1_022_000_008, "工单当前状态不允许此操作");
    ErrorCode TICKET_ARCHIVE_FAIL_NOT_VISITED = new ErrorCode(1_022_000_009, "归档失败,原因:工单尚未回访通过,请先登记满意的回访记录");
    ErrorCode TICKET_HANDLER_REQUIRED = new ErrorCode(1_022_000_010, "新增售后工单必须选择处理人:请在「处理人」字段选择一名用户后再保存");
    // ========== å”®åŽå·¥å•明细行 1-022-001-000 ==========
    ErrorCode TICKET_ITEM_NOT_EXISTS = new ErrorCode(1_022_001_000, "工单明细行不存在");
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketFlowService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.aftersales.service.ticket;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketFlowDO;
import java.util.List;
/**
 * å”®åŽå·¥å•流转记录 Service æŽ¥å£
 */
public interface AfterSaleTicketFlowService {
    // ========== åŠ¨ä½œç¼–ç  ==========
    String ACTION_CREATE = "CREATE";
    String ACTION_UPDATE = "UPDATE";
    String ACTION_ASSIGN = "ASSIGN";
    String ACTION_CLASSIFY = "CLASSIFY";
    String ACTION_RESOLVE = "RESOLVE";
    String ACTION_ARCHIVE = "ARCHIVE";
    String ACTION_CANCEL = "CANCEL";
    String ACTION_VISIT = "VISIT";
    String ACTION_RETURN = "RETURN";
    /**
     * è®°å½•一条流转日志
     *
     * @param ticketId   å·¥å•编号
     * @param action     åŠ¨ä½œç¼–ç 
     * @param actionName åŠ¨ä½œåç§°
     * @param fromStatus å˜æ›´å‰çŠ¶æ€ï¼Œå¯ä¸ºç©º
     * @param toStatus   å˜æ›´åŽçŠ¶æ€ï¼Œå¯ä¸ºç©º
     * @param content    æµè½¬å†…容
     */
    void record(Long ticketId, String action, String actionName,
                Integer fromStatus, Integer toStatus, String content);
    /**
     * èŽ·å¾—å·¥å•çš„æµè½¬è®°å½•åˆ—è¡¨ï¼ˆæŒ‰æ—¶é—´æ­£åºï¼‰
     *
     * @param ticketId å·¥å•编号
     * @return æµè½¬è®°å½•列表
     */
    List<AfterSaleTicketFlowDO> getFlowList(Long ticketId);
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketFlowServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.aftersales.service.ticket;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketFlowDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketFlowMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.List;
/**
 * å”®åŽå·¥å•流转记录 Service å®žçŽ°ç±»
 */
@Service
@Validated
public class AfterSaleTicketFlowServiceImpl implements AfterSaleTicketFlowService {
    @Resource
    private AfterSaleTicketFlowMapper ticketFlowMapper;
    @Override
    public void record(Long ticketId, String action, String actionName,
                       Integer fromStatus, Integer toStatus, String content) {
        Long operatorId = SecurityFrameworkUtils.getLoginUserId();
        AfterSaleTicketFlowDO flow = AfterSaleTicketFlowDO.builder()
                .ticketId(ticketId)
                .action(action)
                .actionName(actionName)
                .fromStatus(fromStatus)
                .toStatus(toStatus)
                .content(content)
                .operatorId(operatorId)
                .build();
        ticketFlowMapper.insert(flow);
    }
    @Override
    public List<AfterSaleTicketFlowDO> getFlowList(Long ticketId) {
        return ticketFlowMapper.selectListByTicketId(ticketId);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketService.java
@@ -72,12 +72,15 @@
    List<AfterSaleTicketItemDO> getTicketItemListByTicketId(Long ticketId);
    /**
     * åˆ†é…å¤„理人
     * ä»“储物流创建销售退货单后,在关联销售订单的售后工单上记录流转
     *
     * @param id              å·¥å•编号
     * @param handlerUserId   å¤„理人用户编号
     * @param saleOrderId     é”€å”®è®¢å•编号
     * @param returnSalesId   MES é”€å”®é€€è´§å•编号
     * @param returnSalesCode MES é”€å”®é€€è´§å•号
     * @param returnReason    é€€è´§åŽŸå› 
     */
    void assignTicket(Long id, Long handlerUserId);
    void onWarehouseReturnCreated(Long saleOrderId, Long returnSalesId,
                                  String returnSalesCode, String returnReason);
    /**
     * é—®é¢˜åˆ¤å®šï¼ˆç¡®å®šé—®é¢˜ç±»åž‹ã€åˆ†ç±»ã€ä¸¥é‡ç¨‹åº¦ï¼‰
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
@@ -12,6 +12,7 @@
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper;
import cn.iocoder.yudao.module.aftersales.dal.redis.no.AfterSaleNoRedisDAO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleBizTypeEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleIssueTypeEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleVisitStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.permission.AfterSalePermissionLevelEnum;
@@ -19,6 +20,8 @@
import cn.iocoder.yudao.module.aftersales.service.permission.bo.AfterSalePermissionCreateReqBO;
import cn.iocoder.yudao.module.aftersales.service.problem.AfterSaleProblemService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -27,6 +30,7 @@
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -39,6 +43,13 @@
@Validated
@Slf4j
public class AfterSaleTicketServiceImpl implements AfterSaleTicketService {
    /** å®¢æˆ·è¯‰æ±‚附件记录类型 */
    private static final String FEEDBACK_RECORD_TYPE = "aftersales_after_sale_ticket_feedback";
    /** å¤„理结果附件记录类型 */
    private static final String RESULT_RECORD_TYPE = "aftersales_after_sale_ticket_result";
    /** é€šç”¨é™„件记录类型(历史数据) */
    private static final String LEGACY_RECORD_TYPE = "aftersales_after_sale_ticket";
    @Resource
    private AfterSaleTicketMapper ticketMapper;
@@ -57,23 +68,34 @@
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private AfterSaleTicketFlowService ticketFlowService;
    @Resource
    private AdminUserApi adminUserApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createTicket(AfterSaleTicketSaveReqVO createReqVO, Long userId) {
        // 1. ç”Ÿæˆå•号
        // 1. æ ¡éªŒå¤„理人:新增即分配,不再提供单独的分配处理人动作
        if (createReqVO.getHandlerUserId() == null) {
            throw exception(TICKET_HANDLER_REQUIRED);
        }
        // 2. ç”Ÿæˆå•号
        String no = noRedisDAO.generate(AfterSaleNoRedisDAO.TICKET_NO_PREFIX);
        if (ticketMapper.selectByNo(no) != null) {
            throw exception(TICKET_NO_EXISTS);
        }
        // 2. æ’入工单
        // 3. æ’入工单:新增时已指定处理人,直接进入处理中
        AfterSaleTicketDO ticket = BeanUtils.toBean(createReqVO, AfterSaleTicketDO.class)
                .setNo(no)
                .setStatus(AfterSaleTicketStatusEnum.DRAFT.getStatus())
                .setStatus(AfterSaleTicketStatusEnum.PROCESSING.getStatus())
                .setOwnerUserId(userId);
        ticketMapper.insert(ticket);
        // 3. æ’入明细行
        // 4. æ’入明细行
        if (CollUtil.isNotEmpty(createReqVO.getItems())) {
            createReqVO.getItems().forEach(item -> {
                AfterSaleTicketItemDO itemDO = BeanUtils.toBean(item, AfterSaleTicketItemDO.class);
@@ -82,11 +104,20 @@
            });
        }
        // 4. ç»‘定附件
        storageAttachmentApi.bindAttachments("file", "aftersales_after_sale_ticket",
        // 5. ç»‘定附件:客户诉求附件、处理结果附件分开存储;历史通用附件字段继续兼容
        storageAttachmentApi.bindAttachments("file", FEEDBACK_RECORD_TYPE,
                ticket.getId(), createReqVO.getFeedbackBlobIds());
        storageAttachmentApi.bindAttachments("file", RESULT_RECORD_TYPE,
                ticket.getId(), createReqVO.getResultBlobIds());
        storageAttachmentApi.bindAttachments("file", LEGACY_RECORD_TYPE,
                ticket.getId(), createReqVO.getBlobIds());
        // 5. åˆ›å»ºæ•°æ®æƒé™ï¼ˆè´Ÿè´£äººï¼‰
        // 6. è®°å½•流转
        String createContent = "创建售后工单【" + no + "】,处理人:" + getHandlerName(createReqVO.getHandlerUserId());
        ticketFlowService.record(ticket.getId(), AfterSaleTicketFlowService.ACTION_CREATE, "创建工单",
                null, ticket.getStatus(), createContent);
        // 7. åˆ›å»ºæ•°æ®æƒé™ï¼ˆè´Ÿè´£äººï¼‰
        AfterSalePermissionCreateReqBO permBO = new AfterSalePermissionCreateReqBO();
        permBO.setBizType(AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType());
        permBO.setBizId(ticket.getId());
@@ -102,10 +133,16 @@
    public void updateTicket(AfterSaleTicketSaveReqVO updateReqVO) {
        // 1. æ ¡éªŒå­˜åœ¨
        AfterSaleTicketDO ticket = validateTicketExists(updateReqVO.getId());
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT);
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING);
        // 2. æ›´æ–°å·¥å•
        // 2. æ›´æ–°å·¥å•:历史草稿补录处理人后直接进入处理中,避免因取消分配节点而卡在草稿
        AfterSaleTicketDO updateObj = BeanUtils.toBean(updateReqVO, AfterSaleTicketDO.class);
        Integer targetStatus = ticket.getStatus();
        if (AfterSaleTicketStatusEnum.DRAFT.getStatus().equals(ticket.getStatus())
                && updateReqVO.getHandlerUserId() != null) {
            targetStatus = AfterSaleTicketStatusEnum.PROCESSING.getStatus();
            updateObj.setStatus(targetStatus);
        }
        ticketMapper.updateById(updateObj);
        // 3. æ›´æ–°æ˜Žç»†è¡Œï¼ˆå…ˆåˆ åŽå¢žï¼‰
@@ -118,8 +155,17 @@
            });
        }
        // 4. æ›´æ–°é™„ä»¶
        storageAttachmentApi.updateAttachments("file", "aftersales_after_sale_ticket", updateReqVO.getId(), updateReqVO.getBlobIds());
        // 4. æ›´æ–°é™„件:客户诉求、处理结果分别更新;历史通用附件保留原逻辑
        storageAttachmentApi.updateAttachments("file", FEEDBACK_RECORD_TYPE, updateReqVO.getId(),
                updateReqVO.getFeedbackBlobIds());
        storageAttachmentApi.updateAttachments("file", RESULT_RECORD_TYPE, updateReqVO.getId(),
                updateReqVO.getResultBlobIds());
        storageAttachmentApi.updateAttachments("file", LEGACY_RECORD_TYPE, updateReqVO.getId(),
                updateReqVO.getBlobIds());
        // 5. è®°å½•流转
        ticketFlowService.record(ticket.getId(), AfterSaleTicketFlowService.ACTION_UPDATE, "更新工单",
                ticket.getStatus(), targetStatus, buildUpdateFlowContent(updateReqVO, ticket.getHandlerUserId()));
    }
    @Override
@@ -130,7 +176,9 @@
        // è½¯åˆ é™¤
        ticketMapper.deleteById(id);
        ticketItemMapper.deleteByTicketId(id);
        storageAttachmentApi.deleteAttachmentsByRecord("aftersales_after_sale_ticket", id);
        storageAttachmentApi.deleteAttachmentsByRecord(FEEDBACK_RECORD_TYPE, id);
        storageAttachmentApi.deleteAttachmentsByRecord(RESULT_RECORD_TYPE, id);
        storageAttachmentApi.deleteAttachmentsByRecord(LEGACY_RECORD_TYPE, id);
        permissionService.deletePermission(AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType(), id);
    }
@@ -162,11 +210,48 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void assignTicket(Long id, Long handlerUserId) {
        AfterSaleTicketDO ticket = validateTicketExists(id);
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING);
        ticketMapper.updateById(new AfterSaleTicketDO().setId(id).setHandlerUserId(handlerUserId)
                .setStatus(AfterSaleTicketStatusEnum.PROCESSING.getStatus()));
    public void onWarehouseReturnCreated(Long saleOrderId, Long returnSalesId,
                                         String returnSalesCode, String returnReason) {
        if (saleOrderId == null) {
            return;
        }
        // 1. æ‰¾åˆ°è¯¥é”€å”®è®¢å•下最近一张未关闭、未取消的工单
        List<AfterSaleTicketDO> tickets = ticketMapper.selectListBySaleOrderId(saleOrderId);
        AfterSaleTicketDO ticket = tickets.stream()
                .filter(item -> !AfterSaleTicketStatusEnum.CLOSED.getStatus().equals(item.getStatus())
                        && !AfterSaleTicketStatusEnum.CANCELED.getStatus().equals(item.getStatus()))
                .max(Comparator.comparing(AfterSaleTicketDO::getId))
                .orElse(null);
        if (ticket == null) {
            return;
        }
        // 2. åŒä¸€é€€è´§å•只记录一次,避免回调重试产生重复记录
        String returnSalesKey = returnSalesCode != null && !returnSalesCode.isBlank()
                ? returnSalesCode : "ID=" + returnSalesId;
        boolean recorded = ticketFlowService.getFlowList(ticket.getId()).stream()
                .anyMatch(flow -> AfterSaleTicketFlowService.ACTION_RETURN.equals(flow.getAction())
                        && flow.getContent() != null
                        && flow.getContent().contains("【" + returnSalesKey + "】"));
        if (recorded) {
            return;
        }
        // 3. è‰ç¨¿/处理中的工单进入待退货;其他状态只留痕,不改状态
        Integer targetStatus = ticket.getStatus();
        if (AfterSaleTicketStatusEnum.DRAFT.getStatus().equals(targetStatus)
                || AfterSaleTicketStatusEnum.PROCESSING.getStatus().equals(targetStatus)) {
            targetStatus = AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus();
            ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId()).setStatus(targetStatus));
        }
        // 4. å†™å…¥æµè½¬è®°å½•
        String content = "仓储物流创建销售退货单【" + returnSalesKey + "】"
                + (returnReason != null && !returnReason.isBlank() ? ",退货原因:" + returnReason : "");
        ticketFlowService.record(ticket.getId(), AfterSaleTicketFlowService.ACTION_RETURN, "仓储退货",
                ticket.getStatus(), targetStatus, content);
    }
    private String getHandlerName(Long handlerUserId) {
        AdminUserRespDTO handler = adminUserApi.getUser(handlerUserId);
        return handler != null ? handler.getNickname() : "用户(ID=" + handlerUserId + ")";
    }
    @Override
@@ -176,6 +261,12 @@
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING);
        ticketMapper.updateById(new AfterSaleTicketDO().setId(id)
                .setIssueType(issueType).setIssueCategory(issueCategory).setSeverityLevel(severityLevel));
        // è®°å½•流转
        ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_CLASSIFY, "问题判定",
                ticket.getStatus(), ticket.getStatus(),
                "问题判定:类型=" + getIssueTypeName(issueType)
                        + ",分类=" + (issueCategory != null && !issueCategory.isBlank() ? issueCategory : "未填写")
                        + ",严重程度=" + getSeverityName(severityLevel));
    }
    @Override
@@ -189,6 +280,10 @@
                .setStatus(AfterSaleTicketStatusEnum.COMPLETED.getStatus())
                .setResolvedTime(LocalDateTime.now())
                .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus()));
        // è®°å½•流转
        ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_RESOLVE, "完结工单",
                ticket.getStatus(), AfterSaleTicketStatusEnum.COMPLETED.getStatus(),
                "工单处理完成,进入待回访");
    }
    @Override
@@ -209,6 +304,10 @@
                .setClosedTime(LocalDateTime.now())
                .setProblemId(problem.getId())
                .setProblemNo(problem.getNo()));
        // è®°å½•流转
        ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_ARCHIVE, "归档工单",
                AfterSaleTicketStatusEnum.COMPLETED.getStatus(), AfterSaleTicketStatusEnum.CLOSED.getStatus(),
                "归档工单,沉淀问题【" + problem.getNo() + "】");
    }
    @Override
@@ -218,10 +317,59 @@
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING);
        ticketMapper.updateById(new AfterSaleTicketDO().setId(id)
                .setStatus(AfterSaleTicketStatusEnum.CANCELED.getStatus()));
        // è®°å½•流转
        ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_CANCEL, "取消工单",
                ticket.getStatus(), AfterSaleTicketStatusEnum.CANCELED.getStatus(), "取消工单");
    }
    // ========== å†…部方法 ==========
    private String buildUpdateFlowContent(AfterSaleTicketSaveReqVO reqVO, Long originalHandlerUserId) {
        StringBuilder content = new StringBuilder("更新工单信息");
        if (reqVO.getHandlerUserId() != null
                && !reqVO.getHandlerUserId().equals(originalHandlerUserId)) {
            content.append(",处理人变更为:").append(getHandlerName(reqVO.getHandlerUserId()));
        }
        if (reqVO.getFeedbackDescription() != null && !reqVO.getFeedbackDescription().isBlank()) {
            content.append(",客户诉求反馈已更新");
        }
        if (CollUtil.isNotEmpty(reqVO.getFeedbackBlobIds())) {
            content.append(",客户诉求附件 ").append(reqVO.getFeedbackBlobIds().size()).append(" ä¸ª");
        }
        if (reqVO.getResultDescription() != null && !reqVO.getResultDescription().isBlank()) {
            content.append(",处理结果已更新");
        }
        if (CollUtil.isNotEmpty(reqVO.getResultBlobIds())) {
            content.append(",处理结果附件 ").append(reqVO.getResultBlobIds().size()).append(" ä¸ª");
        }
        return content.toString();
    }
    private String getIssueTypeName(Integer issueType) {
        if (issueType == null) {
            return AfterSaleIssueTypeEnum.UNCLASSIFIED.getName();
        }
        for (AfterSaleIssueTypeEnum item : AfterSaleIssueTypeEnum.values()) {
            if (item.getType().equals(issueType)) {
                return item.getName();
            }
        }
        return "未知类型(ID=" + issueType + ")";
    }
    private String getSeverityName(Integer severityLevel) {
        if (severityLevel == null) {
            return "未填写";
        }
        return switch (severityLevel) {
            case 0 -> "轻微";
            case 1 -> "一般";
            case 2 -> "严重";
            case 3 -> "致命";
            default -> "未知(ID=" + severityLevel + ")";
        };
    }
    private AfterSaleTicketDO validateTicketExists(Long id) {
        AfterSaleTicketDO ticket = ticketMapper.selectById(id);
        if (ticket == null) {
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitServiceImpl.java
@@ -11,6 +11,8 @@
import cn.iocoder.yudao.module.aftersales.dal.mysql.visit.AfterSaleVisitMapper;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleVisitStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleVisitTypeEnum;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketFlowService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -40,6 +42,8 @@
    private AfterSaleTicketMapper ticketMapper;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private AfterSaleTicketFlowService ticketFlowService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -80,6 +84,15 @@
                    .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus());
        }
        ticketMapper.updateById(update);
        // 5. è®°å½•流转:回访通过则保持已完结,不满意/复发则退回处理中
        Integer toStatus = satisfied && !recurred
                ? AfterSaleTicketStatusEnum.COMPLETED.getStatus()
                : AfterSaleTicketStatusEnum.PROCESSING.getStatus();
        ticketFlowService.record(ticket.getId(), AfterSaleTicketFlowService.ACTION_VISIT, "登记回访",
                ticket.getStatus(), toStatus,
                "登记回访:" + AfterSaleVisitTypeEnum.getNameByType(createReqVO.getVisitType())
                        + ",客户" + (satisfied ? "满意" : "不满意")
                        + ",问题" + (recurred ? "复发" : "未复发"));
        return visitDO.getId();
    }
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/event/BpmProcessInstanceStatusEvent.java
@@ -24,6 +24,11 @@
    @NotNull(message = "流程实例的 key ä¸èƒ½ä¸ºç©º")
    private String processDefinitionKey;
    /**
     * æµç¨‹åˆ†ç±»ç¼–码。
     * ä¸šåŠ¡ç›‘å¬å™¨æŒ‰æµç¨‹ key æ— æ³•匹配时(如自定义流程模型的 key ä¸º C000_abc. è¿™ç±»éšæœºå€¼ï¼‰ï¼Œå¯å›žé€€æŒ‰åˆ†ç±»åŒ¹é…
     */
    private String processDefinitionCategory;
    /**
     * æµç¨‹å®žä¾‹çš„结果
     */
    @NotNull(message = "流程实例的状态不能为空")
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/api/event/BpmProcessInstanceStatusEventListener.java
@@ -13,7 +13,9 @@
    @Override
    public final void onApplicationEvent(BpmProcessInstanceStatusEvent event) {
        if (!StrUtil.equals(event.getProcessDefinitionKey(), getProcessDefinitionKey())) {
        if (!StrUtil.equals(event.getProcessDefinitionKey(), getProcessDefinitionKey())
                // å…œåº•:自定义流程模型的 key å¯èƒ½æ˜¯éšæœºå€¼ï¼Œæ­¤æ—¶æŒ‰æµç¨‹åˆ†ç±»ï¼ˆä¸šåŠ¡åˆ†ç±»ç¼–ç ï¼‰åŒ¹é…
                && !StrUtil.equals(event.getProcessDefinitionCategory(), getProcessDefinitionKey())) {
            return;
        }
        onEvent(event);
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/definition/BpmProcessDefinitionController.java
@@ -96,8 +96,11 @@
        });
        // 2. æ‹¼æŽ¥ VO è¿”回
        // è¡¥å……表单信息:历史部署 / é…ç½®å¯¼å…¥çš„æµç¨‹å®šä¹‰å¯èƒ½æ²¡æœ‰ formConf、formFields,按 formId ä»Ž bpm_form å›žè¯»
        Map<Long, BpmFormDO> formMap = formService.getFormMap(
                convertSet(processDefinitionMap.values(), BpmProcessDefinitionInfoDO::getFormId));
        return success(BpmProcessDefinitionConvert.INSTANCE.buildProcessDefinitionList(
                list, null, processDefinitionMap, null, null));
                list, null, processDefinitionMap, formMap, null));
    }
    @GetMapping("/simple-list")
@@ -125,8 +128,11 @@
        }
        BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionService.getProcessDefinitionInfo(processDefinition.getId());
        BpmnModel bpmnModel = processDefinitionService.getProcessDefinitionBpmnModel(processDefinition.getId());
        // æ™®é€šè¡¨å•兜底:按 formId å›žè¯» bpm_form,避免 formConf、formFields ä¸ºç©ºå¯¼è‡´å‘起流程页表单空白
        BpmFormDO form = processDefinitionInfo != null && processDefinitionInfo.getFormId() != null
                ? formService.getForm(processDefinitionInfo.getFormId()) : null;
        return success(BpmProcessDefinitionConvert.INSTANCE.buildProcessDefinition(
                processDefinition, null, processDefinitionInfo, null, null, bpmnModel));
                processDefinition, null, processDefinitionInfo, form, null, bpmnModel));
    }
}
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/convert/definition/BpmProcessDefinitionConvert.java
@@ -9,6 +9,7 @@
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmCategoryDO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmFormDO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum;
import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils;
import org.flowable.bpmn.model.BpmnModel;
import org.flowable.common.engine.impl.db.SuspensionState;
@@ -22,6 +23,7 @@
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
 * Bpm æµç¨‹å®šä¹‰çš„ Convert
@@ -80,6 +82,10 @@
            // Form
            if (form != null) {
                respVO.setFormName(form.getName());
                // æ™®é€šè¡¨å•:以 bpm_form çš„æœ€æ–°é…ç½®ä¸ºå‡†ï¼Œé¿å…åŽ†å²éƒ¨ç½² / é…ç½®å¯¼å…¥çš„æ•°æ®ç¼ºå¤± formConf、formFields
                if (Objects.equals(respVO.getFormType(), BpmModelFormTypeEnum.NORMAL.getType())) {
                    respVO.setFormConf(form.getConf()).setFormFields(form.getFields());
                }
            }
        }
        // Category
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/convert/task/BpmProcessInstanceConvert.java
@@ -124,9 +124,11 @@
    void copyTo(BpmProcessDefinitionInfoDO from, @MappingTarget BpmProcessDefinitionRespVO to);
    default BpmProcessInstanceStatusEvent buildProcessInstanceStatusEvent(Object source, ProcessInstance instance,
                                                                          Integer status, String reason) {
                                                                          String category, Integer status, String reason) {
        return new BpmProcessInstanceStatusEvent(source).setId(instance.getId()).setStatus(status).setReason(reason)
                .setProcessDefinitionKey(instance.getProcessDefinitionKey()).setBusinessKey(instance.getBusinessKey());
                .setProcessDefinitionKey(instance.getProcessDefinitionKey())
                .setProcessDefinitionCategory(category)
                .setBusinessKey(instance.getBusinessKey());
    }
    default BpmMessageSendWhenProcessInstanceApproveReqDTO buildProcessInstanceApproveMessage(ProcessInstance instance) {
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/attach/BpmBusinessAttachServiceImpl.java
@@ -105,6 +105,11 @@
        }
        // æœªå‘½ä¸­é…ç½® / é…ç½®è¢«ç¦ç”¨ï¼šä¿æŒåŽŸæ ·
        BpmBusinessAttachDO attach = getBusinessAttachByProcessDefinitionKey(processDefinitionKey);
        // å…œåº•:自定义流程模型的 key(如 C000_abc.)与业务分类不一致时,按流程分类匹配业务详情组件。
        // ä¸šåŠ¡é™„ä»¶é…ç½®çš„ key å®žé™…存的是业务分类编码(purchase_request_approve ç­‰ï¼‰ï¼Œå†…置流程的 key æ°å¥½ä¸Žåˆ†ç±»ç›¸åŒã€‚
        if (attach == null && StrUtil.isNotBlank(definitionResp.getCategory())) {
            attach = getBusinessAttachByProcessDefinitionKey(definitionResp.getCategory());
        }
        if (attach == null || !Objects.equals(attach.getStatus(), CommonStatusEnum.ENABLE.getStatus())) {
            return;
        }
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java
@@ -15,13 +15,16 @@
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelMetaInfoVO;
import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO;
import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.process.BpmProcessDefinitionRespVO;
import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.*;
import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmApprovalDetailRespVO.ActivityNodeTask;
import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.BpmTaskRespVO;
import cn.iocoder.yudao.module.bpm.convert.task.BpmProcessInstanceConvert;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmFormDO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.dal.redis.BpmProcessIdRedisDAO;
import cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum;
import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum;
import cn.iocoder.yudao.module.bpm.enums.task.BpmAttachmentTypeEnum;
@@ -38,6 +41,7 @@
import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils;
import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.SimpleModelUtils;
import cn.iocoder.yudao.module.bpm.service.attach.BpmBusinessAttachService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmFormService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.bpm.service.message.BpmMessageService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
@@ -127,6 +131,8 @@
    @Resource
    private BpmBusinessAttachService businessAttachService;
    @Resource
    private BpmFormService formService;
    // ========== Query æŸ¥è¯¢ç›¸å…³æ–¹æ³• ==========
@@ -397,10 +403,34 @@
                processDefinition, processDefinitionInfo, processInstance,
                processInstanceStatus, approveNodes, todoTask, formFieldsPermission, userMap, deptMap);
        // 3.1 æ™®é€šè¡¨å•兜底:历史部署 / é…ç½®å¯¼å…¥çš„æ•°æ®ï¼Œbpm_process_definition_info å¯èƒ½æ²¡æœ‰ formConf、formFields,
        // æ­¤æ—¶æŒ‰ formId å›žè¯» bpm_form,避免审批详情页表单区域空白
        fillNormalForm(detailRespVO.getProcessDefinition(), processDefinitionInfo);
        // 4. å¡«å……业务附件配置,实现审批详情自动展示外部业务信息
        businessAttachService.fillBusinessForm(detailRespVO.getProcessDefinition(), processDefinition.getKey(),
                processInstance != null ? processInstance.getBusinessKey() : null);
        return detailRespVO;
    }
    /**
     * æ™®é€šè¡¨å•兜底填充:历史部署或配置导入的流程定义,可能只有 formId è€Œç¼ºå¤± formConf、formFields,
     * å¯¼è‡´å®¡æ‰¹è¯¦æƒ…页的表单区域空白。这里按 formId å›žè¯» bpm_form è¡¥é½ã€‚
     */
    private void fillNormalForm(BpmProcessDefinitionRespVO definition, BpmProcessDefinitionInfoDO processDefinitionInfo) {
        if (definition == null || !Objects.equals(definition.getFormType(), BpmModelFormTypeEnum.NORMAL.getType())) {
            return;
        }
        Long formId = definition.getFormId() != null ? definition.getFormId()
                : (processDefinitionInfo != null ? processDefinitionInfo.getFormId() : null);
        if (formId == null
                || (StrUtil.isNotBlank(definition.getFormConf()) && CollUtil.isNotEmpty(definition.getFormFields()))) {
            return;
        }
        BpmFormDO form = formService.getForm(formId);
        if (form != null) {
            definition.setFormConf(form.getConf()).setFormFields(form.getFields());
        }
    }
    /**
@@ -1025,8 +1055,11 @@
        }
        // 3. å‘送流程实例的状态事件
        // å¸¦ä¸Šæµç¨‹åˆ†ç±»ï¼šè‡ªå®šä¹‰æµç¨‹æ¨¡åž‹çš„ key å¯èƒ½æ˜¯éšæœºå€¼ï¼ˆå¦‚ C000_abc.),业务监听器需要按分类兜底匹配
        ProcessDefinition processDefinition = processDefinitionService.getProcessDefinition(instance.getProcessDefinitionId());
        processInstanceEventPublisher.sendProcessInstanceResultEvent(
                BpmProcessInstanceConvert.INSTANCE.buildProcessInstanceStatusEvent(this, instance, status, reason));
                BpmProcessInstanceConvert.INSTANCE.buildProcessInstanceStatusEvent(this, instance,
                        processDefinition != null ? processDefinition.getCategory() : null, status, reason));
        // 4. æµç¨‹åŽç½®é€šçŸ¥
        if (Objects.equals(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus())) {
yudao-module-crm/pom.xml
@@ -55,6 +55,20 @@
            <version>${revision}</version>
        </dependency>
        <!-- MES é”€å”®å‡ºåº“单 API(开票绑定出库单、应收对账) -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-mes-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- HRM ç”¨æˆ·æ•°æ®äº¤æŽ¥ API(离职交接后交接人可见离职人数据) -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-hrm-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- ä¸šåŠ¡ç»„ä»¶ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/CrmFinanceReportController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
package cn.iocoder.yudao.module.crm.controller.admin.finance;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmCustomerStatementRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmFinanceTrendRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmShippedNotInvoicedRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmUnwrittenOffRespVO;
import cn.iocoder.yudao.module.crm.service.finance.CrmFinanceReportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ç®¡ç†åŽå° - CRM è´¢åŠ¡å¯¹è´¦ä¸ŽæŠ¥è¡¨
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - CRM è´¢åŠ¡å¯¹è´¦ä¸ŽæŠ¥è¡¨")
@RestController
@RequestMapping("/crm/finance/report")
@Validated
public class CrmFinanceReportController {
    @Resource
    private CrmFinanceReportService financeReportService;
    @GetMapping("/customer-statement")
    @Operation(summary = "获得客户应收对账单", description = "按客户统计所有已审核销售订单的订单金额、出库金额、开票金额、回款金额、未核销应收及下月回款计划")
    @Parameter(name = "customerId", description = "客户编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:receivable:query')")
    public CommonResult<CrmCustomerStatementRespVO> getCustomerStatement(@RequestParam("customerId") Long customerId) {
        return success(financeReportService.getCustomerStatement(customerId));
    }
    @GetMapping("/shipped-not-invoiced")
    @Operation(summary = "获得已发货未开票明细")
    @Parameter(name = "customerId", description = "客户编号,不传时统计全部客户")
    @PreAuthorize("@ss.hasPermission('crm:invoice:query')")
    public CommonResult<List<CrmShippedNotInvoicedRespVO>> getShippedNotInvoicedList(
            @RequestParam(value = "customerId", required = false) Long customerId) {
        return success(financeReportService.getShippedNotInvoicedList(customerId));
    }
    @GetMapping("/unwritten-off")
    @Operation(summary = "获得未核销应收明细", description = "已审批通过开票中仍有未回款金额的部分,返回账龄")
    @Parameter(name = "customerId", description = "客户编号,不传时统计全部客户")
    @PreAuthorize("@ss.hasPermission('crm:receivable:query')")
    public CommonResult<List<CrmUnwrittenOffRespVO>> getUnwrittenOffList(
            @RequestParam(value = "customerId", required = false) Long customerId) {
        return success(financeReportService.getUnwrittenOffList(customerId));
    }
    @GetMapping("/monthly-trend")
    @Operation(summary = "获得财务月度趋势", description = "返回最近 N ä¸ªæœˆçš„开票金额(审批通过)与回款金额(审批通过)")
    @Parameter(name = "months", description = "月份数量,默认 6,范围 1-36")
    @PreAuthorize("@ss.hasPermission('crm:receivable:query')")
    public CommonResult<List<CrmFinanceTrendRespVO>> getMonthlyTrend(
            @RequestParam(value = "months", required = false, defaultValue = "6") Integer months) {
        return success(financeReportService.getMonthlyTrend(months));
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmCustomerStatementRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
package cn.iocoder.yudao.module.crm.controller.admin.finance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
 * å®¢æˆ·åº”收对账单 Response VO
 */
@Schema(description = "管理后台 - å®¢æˆ·åº”收对账单 Response VO")
@Data
public class CrmCustomerStatementRespVO {
    @Schema(description = "客户编号")
    private Long customerId;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "订单数量")
    private Integer orderCount;
    @Schema(description = "订单总金额")
    private BigDecimal orderTotalAmount;
    @Schema(description = "已出库金额")
    private BigDecimal shippedAmount;
    @Schema(description = "已开票金额(审批通过)")
    private BigDecimal invoicedAmount;
    @Schema(description = "已回款金额(审批通过)")
    private BigDecimal collectedAmount;
    @Schema(description = "已出库未开票金额")
    private BigDecimal shippedNotInvoicedAmount;
    @Schema(description = "未核销应收金额(订单金额 - å·²å›žæ¬¾ï¼‰")
    private BigDecimal outstandingAmount;
    @Schema(description = "下月计划回款金额")
    private BigDecimal nextMonthPlanAmount;
    @Schema(description = "订单明细")
    private List<OrderRow> orders;
    @Schema(description = "订单行")
    @Data
    public static class OrderRow {
        @Schema(description = "销售订单编号")
        private Long saleOrderId;
        @Schema(description = "销售订单号")
        private String saleOrderNo;
        @Schema(description = "合同编号")
        private Long contractId;
        @Schema(description = "下单时间")
        private LocalDateTime orderTime;
        @Schema(description = "订单金额")
        private BigDecimal orderAmount;
        @Schema(description = "已出库金额")
        private BigDecimal shippedAmount;
        @Schema(description = "已开票金额")
        private BigDecimal invoicedAmount;
        @Schema(description = "已回款金额")
        private BigDecimal collectedAmount;
        @Schema(description = "已出库未开票金额")
        private BigDecimal shippedNotInvoicedAmount;
        @Schema(description = "未核销应收金额")
        private BigDecimal outstandingAmount;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmFinanceTrendRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.crm.controller.admin.finance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * è´¢åŠ¡æœˆåº¦è¶‹åŠ¿ Response VO
 */
@Schema(description = "管理后台 - è´¢åŠ¡æœˆåº¦è¶‹åŠ¿ Response VO")
@Data
public class CrmFinanceTrendRespVO {
    @Schema(description = "月份,格式 yyyy-MM", example = "2026-11")
    private String month;
    @Schema(description = "开票金额(审批通过)")
    private BigDecimal invoicedAmount;
    @Schema(description = "回款金额(审批通过)")
    private BigDecimal collectedAmount;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmShippedNotInvoicedRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.crm.controller.admin.finance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å·²å‘货未开票明细 Response VO
 */
@Schema(description = "管理后台 - å·²å‘货未开票明细 Response VO")
@Data
public class CrmShippedNotInvoicedRespVO {
    @Schema(description = "出库单编号")
    private Long saleOutId;
    @Schema(description = "出库单号")
    private String saleOutCode;
    @Schema(description = "出库日期")
    private LocalDateTime salesDate;
    @Schema(description = "销售订单编号")
    private Long saleOrderId;
    @Schema(description = "销售订单号")
    private String saleOrderNo;
    @Schema(description = "客户编号")
    private Long customerId;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "出库金额")
    private BigDecimal outAmount;
    @Schema(description = "已开票金额")
    private BigDecimal invoicedAmount;
    @Schema(description = "未开票金额")
    private BigDecimal remainInvoiceAmount;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/finance/vo/CrmUnwrittenOffRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.crm.controller.admin.finance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * æœªæ ¸é”€åº”收明细 Response VO
 */
@Schema(description = "管理后台 - æœªæ ¸é”€åº”收明细 Response VO")
@Data
public class CrmUnwrittenOffRespVO {
    @Schema(description = "开票单编号")
    private Long invoiceId;
    @Schema(description = "开票编号")
    private String invoiceNo;
    @Schema(description = "发票号码")
    private String taxInvoiceNo;
    @Schema(description = "客户编号")
    private Long customerId;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "合同编号")
    private Long contractId;
    @Schema(description = "销售订单编号")
    private Long saleOrderId;
    @Schema(description = "开票日期")
    private LocalDateTime invoiceTime;
    @Schema(description = "开票金额")
    private BigDecimal invoiceAmount;
    @Schema(description = "已回款金额")
    private BigDecimal collectedAmount;
    @Schema(description = "未核销金额")
    private BigDecimal remainAmount;
    @Schema(description = "账龄天数")
    private Long agingDays;
    @Schema(description = "账龄区间:0-30天 31-60天 61-90天 90天以上")
    private String agingBucket;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java
@@ -10,14 +10,17 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceEntryReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportExcelVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoicePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaleOutRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
@@ -93,6 +96,24 @@
        return success(true);
    }
    @PutMapping("/entry")
    @Operation(summary = "开票申请录入发票", description = "开票申请审批通过后,录入真实发票号码、开票日期、附件,并填写收款节点")
    @PreAuthorize("@ss.hasPermission('crm:invoice:update')")
    public CommonResult<Boolean> entryInvoice(@Valid @RequestBody CrmInvoiceEntryReqVO entryReqVO) {
        invoiceService.entryInvoice(entryReqVO);
        return success(true);
    }
    @GetMapping("/sale-out-list")
    @Operation(summary = "获得开票可选的销售出库单", description = "按销售订单查询 MES å·²å®Œæˆå‡ºåº“单,返回已开票金额与剩余可开票金额")
    @Parameter(name = "saleOrderId", description = "销售订单编号", required = true)
    @Parameter(name = "id", description = "开票单编号(编辑时传递,用于排除自身已占用金额)")
    public CommonResult<List<CrmInvoiceSaleOutRespVO>> getSaleOutListForInvoice(
            @RequestParam("saleOrderId") Long saleOrderId,
            @RequestParam(value = "id", required = false) Long id) {
        return success(invoiceService.getSaleOutListForInvoice(saleOrderId, id));
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除开票")
    @Parameter(name = "id", description = "编号", required = true)
@@ -114,7 +135,26 @@
        if (invoice == null) {
            return null;
        }
        return buildInvoiceDetailList(Collections.singletonList(invoice)).get(0);
        CrmInvoiceRespVO invoiceVO = buildInvoiceDetailList(Collections.singletonList(invoice)).get(0);
        // è¯¦æƒ…页补充收款节点(列表页不返回,避免 N+1 æŸ¥è¯¢ï¼‰
        List<CrmReceivablePlanDO> plans = invoiceService.getPaymentNodeListByInvoiceId(invoice.getId());
        if (CollUtil.isNotEmpty(plans)) {
            List<CrmInvoiceRespVO.PaymentNode> nodes = new ArrayList<>(plans.size());
            for (CrmReceivablePlanDO plan : plans) {
                CrmInvoiceRespVO.PaymentNode node = new CrmInvoiceRespVO.PaymentNode();
                node.setId(plan.getId());
                node.setStage(plan.getStage());
                node.setAmount(plan.getPrice());
                node.setReturnTime(plan.getReturnTime());
                node.setActualTime(plan.getActualTime());
                node.setReceiptedPrice(plan.getReceiptedPrice());
                node.setStatus(plan.getStatus());
                node.setRemark(plan.getRemark());
                nodes.add(node);
            }
            invoiceVO.setPaymentNodes(nodes);
        }
        return invoiceVO;
    }
    @GetMapping("/page")
@@ -231,8 +271,9 @@
        if (CollUtil.isEmpty(invoices)) {
            return success(Collections.emptyList());
        }
        // è¿‡æ»¤ï¼šå·²ä¸Šä¼ å‘票附件
        invoices.removeIf(invoice -> CollUtil.isEmpty(storageAttachmentApi.listAttachments(
        // è¿‡æ»¤ï¼šå¿…须是已录入发票(entryStatus=10);历史数据无录入状态时,以是否存在发票附件兜底
        invoices.removeIf(invoice -> !Integer.valueOf(10).equals(invoice.getEntryStatus())
                && CollUtil.isEmpty(storageAttachmentApi.listAttachments(
                StorageRecordTypeEnum.CRM_INVOICE.getType(), invoice.getId())));
        if (CollUtil.isEmpty(invoices)) {
            return success(Collections.emptyList());
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceEntryReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
 * å¼€ç¥¨ç”³è¯·å½•入发票 Request VO
 *
 * å¼€ç¥¨ç”³è¯·å®¡æ‰¹é€šè¿‡åŽï¼Œè´¢åŠ¡åœ¨æ­¤çŽ¯èŠ‚å½•å…¥çœŸå®žå‘ç¥¨å·ç ã€å¼€ç¥¨æ—¥æœŸã€å‘ç¥¨é™„ä»¶ï¼Œ
 * å¹¶æŒ‰é”€å”®è®¢å•填写合同款、预付款、到货款、验收款等收款节点。
 */
@Schema(description = "管理后台 - å¼€ç¥¨ç”³è¯·å½•入发票 Request VO")
@Data
public class CrmInvoiceEntryReqVO {
    @Schema(description = "开票单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @NotNull(message = "开票单编号不能为空")
    private Long id;
    @Schema(description = "发票号码", requiredMode = Schema.RequiredMode.REQUIRED, example = "02564178")
    @NotBlank(message = "发票号码不能为空:请在录入发票时填写真实发票号码")
    private String invoiceNo;
    @Schema(description = "发票抬头", example = "XX科技有限公司")
    private String invoiceTitle;
    @Schema(description = "开票日期", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "开票日期不能为空:请选择发票上的开票日期")
    private LocalDateTime invoiceTime;
    @Schema(description = "发票附件 BlobId åˆ—表")
    private List<Long> blobIds;
    @Schema(description = "收款节点(合同款/预付款/到货款/验收款/质保金)")
    private List<CrmInvoiceSaveReqVO.PaymentNode> paymentNodes;
    @Schema(description = "备注")
    private String remark;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java
@@ -70,6 +70,41 @@
    private Long saleOrderId;
    @Schema(description = "关联出库单")
    private Long saleOutId;
    @Schema(description = "出库单号", example = "SL20261029001")
    private String saleOutCode;
    @Schema(description = "发票录入状态:0待录入 10已录入", example = "0")
    private Integer entryStatus;
    @Schema(description = "发票录入人")
    private Long entryUserId;
    @Schema(description = "发票录入时间")
    private LocalDateTime entryTime;
    @Schema(description = "收款节点")
    private List<PaymentNode> paymentNodes;
    @Schema(description = "收款节点")
    @Data
    public static class PaymentNode {
        @Schema(description = "回款计划编号")
        private Long id;
        @Schema(description = "阶段:1合同款 2预付款 3到货款 4验收款 5质保金")
        private Integer stage;
        @Schema(description = "计划回款金额")
        private BigDecimal amount;
        @Schema(description = "计划回款时间")
        private LocalDateTime returnTime;
        @Schema(description = "实际回款时间")
        private LocalDateTime actualTime;
        @Schema(description = "已回款金额")
        private BigDecimal receiptedPrice;
        @Schema(description = "状态:0未回款 10部分回款 20已回清")
        private Integer status;
        @Schema(description = "备注")
        private String remark;
    }
    @Schema(description = "合同信息")
    private CrmContractRespVO contract;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceSaleOutRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å¼€ç¥¨å¯é€‰å‡ºåº“单 Response VO
 *
 * æ•°æ®æ¥æºï¼šMES é”€å”®å‡ºåº“单(mes_wm_product_sales)
 */
@Schema(description = "管理后台 - å¼€ç¥¨å¯é€‰å‡ºåº“单 Response VO")
@Data
public class CrmInvoiceSaleOutRespVO {
    @Schema(description = "出库单编号", example = "1024")
    private Long id;
    @Schema(description = "出库单号", example = "SL20261029001")
    private String code;
    @Schema(description = "销售订单编号", example = "2048")
    private Long saleOrderId;
    @Schema(description = "销售订单号", example = "SO20261029001")
    private String salesOrderCode;
    @Schema(description = "出库日期")
    private LocalDateTime salesDate;
    @Schema(description = "出库单状态", example = "4")
    private Integer status;
    @Schema(description = "出库单状态名称", example = "已完成")
    private String statusName;
    @Schema(description = "出库金额,单位:元", example = "10000.00")
    private BigDecimal totalPrice;
    @Schema(description = "该出库单已开票金额,单位:元", example = "2000.00")
    private BigDecimal invoicedPrice;
    @Schema(description = "剩余可开票金额,单位:元", example = "8000.00")
    private BigDecimal remainInvoicePrice;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceSaveReqVO.java
@@ -31,6 +31,9 @@
    @Schema(description = "关联出库单")
    private Long saleOutId;
    @Schema(description = "出库单号(后端按 saleOutId å›žå¡«ï¼Œå‰ç«¯æ— éœ€ä¼ é€’)", hidden = true)
    private String saleOutCode;
    @Schema(description = "负责人编号", example = "25682")
    private Long ownerUserId;
@@ -70,4 +73,25 @@
    @Schema(description = "发票附件 BlobId åˆ—表")
    private List<Long> blobIds;
    @Schema(description = "收款节点(合同款/预付款/到货款/验收款/质保金)")
    private List<PaymentNode> paymentNodes;
    @Schema(description = "收款节点")
    @Data
    public static class PaymentNode {
        @Schema(description = "阶段:1合同款 2预付款 3到货款 4验收款 5质保金", requiredMode = Schema.RequiredMode.REQUIRED)
        private Integer stage;
        @Schema(description = "金额", requiredMode = Schema.RequiredMode.REQUIRED)
        private BigDecimal amount;
        @Schema(description = "计划回款时间", requiredMode = Schema.RequiredMode.REQUIRED)
        private LocalDateTime returnTime;
        @Schema(description = "备注")
        private String remark;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java
@@ -11,6 +11,7 @@
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableItemRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableSaveReqVO;
@@ -18,6 +19,8 @@
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableItemDO;
import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableBizTypeEnum;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
import cn.iocoder.yudao.module.crm.service.invoice.CrmInvoiceService;
@@ -189,4 +192,46 @@
        return success(receivableService.getAuditReceivableCount(getLoginUserId()));
    }
    @GetMapping("/item-list")
    @Operation(summary = "获得回款的核销明细")
    @Parameter(name = "receivableId", description = "回款编号", required = true)
    public CommonResult<List<CrmReceivableItemRespVO>> getReceivableItemList(@RequestParam("receivableId") Long receivableId) {
        CrmReceivableDO receivable = receivableService.getReceivable(receivableId);
        return success(buildReceivableItemList(receivableService.getReceivableItemList(receivableId),
                receivable == null ? Collections.emptyList() : Collections.singletonList(receivable)));
    }
    @GetMapping("/item-list-by-customer")
    @Operation(summary = "获得客户下全部回款的核销明细")
    @Parameter(name = "customerId", description = "客户编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:receivable:query')")
    public CommonResult<List<CrmReceivableItemRespVO>> getReceivableItemListByCustomerId(
            @RequestParam("customerId") Long customerId) {
        List<CrmReceivableItemDO> items = receivableService.getReceivableItemListByCustomerId(customerId);
        if (CollUtil.isEmpty(items)) {
            return success(Collections.emptyList());
        }
        Map<Long, CrmReceivableDO> receivableMap = receivableService.getReceivableMap(
                convertSet(items, CrmReceivableItemDO::getReceivableId));
        return success(buildReceivableItemList(items, receivableMap.values()));
    }
    private List<CrmReceivableItemRespVO> buildReceivableItemList(List<CrmReceivableItemDO> items,
                                                                  java.util.Collection<CrmReceivableDO> receivables) {
        if (CollUtil.isEmpty(items)) {
            return Collections.emptyList();
        }
        Map<Long, CrmReceivableDO> receivableMap = receivables.stream()
                .collect(java.util.stream.Collectors.toMap(CrmReceivableDO::getId, item -> item, (a, b) -> a));
        return BeanUtils.toBean(items, CrmReceivableItemRespVO.class, vo -> {
            vo.setBizTypeName(CrmReceivableBizTypeEnum.getNameByType(vo.getBizType()));
            CrmReceivableDO receivable = receivableMap.get(vo.getReceivableId());
            if (receivable != null) {
                vo.setReceivableNo(receivable.getNo());
                vo.setCustomerId(receivable.getCustomerId());
                vo.setReturnTime(receivable.getReturnTime());
            }
        });
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanSaveReqVO.java
@@ -42,4 +42,22 @@
    @Schema(description = "备注", example = "备注")
    private String remark;
    @Schema(description = "收款阶段:1合同款 2预付款 3到货款 4验收款 5质保金", example = "2")
    private Integer stage;
    @Schema(description = "来源开票单编号", example = "1024")
    private Long invoiceId;
    @Schema(description = "来源销售订单编号", example = "1024")
    private Long saleOrderId;
    @Schema(description = "实际回款时间")
    private LocalDateTime actualTime;
    @Schema(description = "已回款金额", example = "0")
    private BigDecimal receiptedPrice;
    @Schema(description = "状态:0未回款 10部分回款 20已回清", example = "0")
    private Integer status;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableItemRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * CRM å›žæ¬¾æ ¸é”€æ˜Žç»† Response VO
 */
@Schema(description = "管理后台 - CRM å›žæ¬¾æ ¸é”€æ˜Žç»† Response VO")
@Data
public class CrmReceivableItemRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "回款编号", example = "1")
    private Long receivableId;
    @Schema(description = "回款单号", example = "HK20261029001")
    private String receivableNo;
    @Schema(description = "客户编号", example = "1")
    private Long customerId;
    @Schema(description = "业务类型:20销售订单 21销售出库 23开票", example = "20")
    private Integer bizType;
    @Schema(description = "业务类型名称", example = "销售订单")
    private String bizTypeName;
    @Schema(description = "业务编号", example = "1")
    private Long bizId;
    @Schema(description = "业务单号", example = "SO20261029001")
    private String bizNo;
    @Schema(description = "应收金额", example = "10000.00")
    private BigDecimal totalPrice;
    @Schema(description = "已核销金额(不含本次)", example = "2000.00")
    private BigDecimal receiptedPrice;
    @Schema(description = "本次核销金额", example = "3000.00")
    private BigDecimal receiptPrice;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "回款日期")
    private LocalDateTime returnTime;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java
@@ -10,6 +10,7 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - CRM å›žæ¬¾æ–°å¢ž/修改 Request VO")
@Data
@@ -59,4 +60,31 @@
    @DiffLogField(name = "备注")
    private String remark;
    @Schema(description = "核销明细(不传时按关联开票自动生成一条)")
    private List<ReceivableItem> items;
    @Schema(description = "核销明细")
    @Data
    public static class ReceivableItem {
        @Schema(description = "业务类型:20销售订单 21销售出库 23开票", requiredMode = Schema.RequiredMode.REQUIRED)
        private Integer bizType;
        @Schema(description = "业务编号", requiredMode = Schema.RequiredMode.REQUIRED)
        private Long bizId;
        @Schema(description = "业务单号(不传时后端按 bizId æŸ¥è¯¢å›žå¡«ï¼‰")
        private String bizNo;
        @Schema(description = "应收金额")
        private BigDecimal totalPrice;
        @Schema(description = "本次核销金额", requiredMode = Schema.RequiredMode.REQUIRED)
        private BigDecimal receiptPrice;
        @Schema(description = "备注")
        private String remark;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/invoice/CrmInvoiceDO.java
@@ -56,6 +56,24 @@
    /** å…³è”出库单 */
    private Long saleOutId;
    /**
     * å‡ºåº“单号(快照)
     */
    private String saleOutCode;
    /**
     * å‘票录入状态
     *
     * 0-待录入 10-已录入
     */
    private Integer entryStatus;
    /**
     * å‘票录入人
     */
    private Long entryUserId;
    /**
     * å‘票录入时间
     */
    private LocalDateTime entryTime;
    /**
     * è´Ÿè´£äººç¼–号,关联 {@link AdminUserRespDTO#getId()}
     */
    private Long ownerUserId;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableItemDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
package cn.iocoder.yudao.module.crm.dal.dataobject.receivable;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
/**
 * CRM å›žæ¬¾æ ¸é”€æ˜Žç»† DO
 *
 * ä¸€æ¡å›žæ¬¾å¯ä»¥æ ¸é”€å¤šä¸ªé”€å”®è®¢å• / é”€å”®å‡ºåº“单 / å¼€ç¥¨ï¼Œæœ¬è¡¨è®°å½•每笔核销的金额。
 *
 * @author è¶…级管理员
 */
@TableName("crm_receivable_item")
@KeySequence("crm_receivable_item_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CrmReceivableItemDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * å›žæ¬¾ç¼–号
     *
     * å…³è” {@link CrmReceivableDO#getId()}
     */
    private Long receivableId;
    /**
     * ä¸šåŠ¡ç±»åž‹
     *
     * æžšä¸¾ {@link cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableBizTypeEnum}
     */
    private Integer bizType;
    /**
     * ä¸šåŠ¡ç¼–å·
     */
    private Long bizId;
    /**
     * ä¸šåŠ¡å•å·ï¼ˆå¿«ç…§ï¼‰
     */
    private String bizNo;
    /**
     * åº”收金额,单位:元
     */
    private BigDecimal totalPrice;
    /**
     * å·²æ ¸é”€é‡‘额,单位:元(不含本次)
     */
    private BigDecimal receiptedPrice;
    /**
     * æœ¬æ¬¡æ ¸é”€é‡‘额,单位:元
     */
    private BigDecimal receiptPrice;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivablePlanDO.java
@@ -89,4 +89,38 @@
     */
    private String remark;
    /**
     * æ”¶æ¬¾é˜¶æ®µ
     *
     * 1-合同款 2-预付款 3-到货款 4-验收款 5-质保金
     */
    private Integer stage;
    /**
     * æ¥æºå¼€ç¥¨å•编号,关联 {@link cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO#getId()}
     */
    private Long invoiceId;
    /**
     * æ¥æºé”€å”®è®¢å•编号
     */
    private Long saleOrderId;
    /**
     * å®žé™…回款时间
     */
    private LocalDateTime actualTime;
    /**
     * å·²å›žæ¬¾é‡‘额,单位:元
     */
    private BigDecimal receiptedPrice;
    /**
     * çŠ¶æ€
     *
     * 0-未回款 10-部分回款 20-已回清
     */
    private Integer status;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java
@@ -34,6 +34,27 @@
        return selectOne(CrmInvoiceDO::getNo, no);
    }
    /**
     * æ¸…空发票录入人、录入时间(updateById é»˜è®¤å¿½ç•¥ null å­—段,清空需显式 update)
     */
    default void clearEntryInfo(Long id) {
        update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getId, id)
                .set(CrmInvoiceDO::getEntryStatus, 0)
                .set(CrmInvoiceDO::getEntryUserId, null)
                .set(CrmInvoiceDO::getEntryTime, null));
    }
    /**
     * æŒ‰å‘票号码查询未被删除的开票(发票号码允许同一张发票多次开票时,返回第一条)
     */
    default CrmInvoiceDO selectByInvoiceNo(String invoiceNo) {
        return selectOne(new LambdaQueryWrapperX<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getInvoiceNo, invoiceNo)
                .orderByAsc(CrmInvoiceDO::getId)
                .last("LIMIT 1"));
    }
    default PageResult<CrmInvoiceDO> selectPageByCustomerId(CrmInvoicePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getCustomerId, reqVO.getCustomerId()) // å¿…须传递
@@ -94,6 +115,24 @@
    }
    /**
     * æŸ¥è¯¢æŒ‡å®šé”€å”®è®¢å•下、指定审批状态的开票
     */
    default List<CrmInvoiceDO> selectListBySaleOrderIdAndStatus(Long saleOrderId, Collection<Integer> auditStatuses) {
        return selectList(new LambdaQueryWrapperX<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getSaleOrderId, saleOrderId)
                .in(CrmInvoiceDO::getAuditStatus, auditStatuses));
    }
    /**
     * æŸ¥è¯¢æŒ‡å®šé”€å”®å‡ºåº“单下、指定审批状态的开票
     */
    default List<CrmInvoiceDO> selectListBySaleOutIdAndStatus(Long saleOutId, Collection<Integer> auditStatuses) {
        return selectList(new LambdaQueryWrapperX<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getSaleOutId, saleOutId)
                .in(CrmInvoiceDO::getAuditStatus, auditStatuses));
    }
    /**
     * æŸ¥è¯¢åˆåŒä¸‹çš„全部开票(导入去重用)
     */
    default List<CrmInvoiceDO> selectListByContractId(Long contractId) {
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableItemMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.crm.dal.mysql.receivable;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableItemDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
 * CRM å›žæ¬¾æ ¸é”€æ˜Žç»† Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface CrmReceivableItemMapper extends BaseMapperX<CrmReceivableItemDO> {
    default List<CrmReceivableItemDO> selectListByReceivableId(Long receivableId) {
        return selectList(new LambdaQueryWrapperX<CrmReceivableItemDO>()
                .eq(CrmReceivableItemDO::getReceivableId, receivableId)
                .orderByAsc(CrmReceivableItemDO::getId));
    }
    default List<CrmReceivableItemDO> selectListByReceivableIds(Collection<Long> receivableIds) {
        if (receivableIds == null || receivableIds.isEmpty()) {
            return Collections.emptyList();
        }
        return selectList(new LambdaQueryWrapperX<CrmReceivableItemDO>()
                .in(CrmReceivableItemDO::getReceivableId, receivableIds)
                .orderByAsc(CrmReceivableItemDO::getId));
    }
    default List<CrmReceivableItemDO> selectListByBiz(Integer bizType, Long bizId) {
        return selectList(new LambdaQueryWrapperX<CrmReceivableItemDO>()
                .eq(CrmReceivableItemDO::getBizType, bizType)
                .eq(CrmReceivableItemDO::getBizId, bizId)
                .orderByAsc(CrmReceivableItemDO::getId));
    }
    default void deleteByReceivableId(Long receivableId) {
        delete(new LambdaQueryWrapperX<CrmReceivableItemDO>()
                .eq(CrmReceivableItemDO::getReceivableId, receivableId));
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
@@ -74,6 +74,12 @@
        return selectCount(query);
    }
    default List<CrmReceivableDO> selectListByCustomerId(Long customerId) {
        return selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<CrmReceivableDO>()
                .eq(CrmReceivableDO::getCustomerId, customerId)
                .orderByDesc(CrmReceivableDO::getId));
    }
    default List<CrmReceivableDO> selectListByContractIdAndStatus(Long contractId, Collection<Integer> auditStatuses) {
        return selectList(new LambdaQueryWrapperX<CrmReceivableDO>()
                .eq(CrmReceivableDO::getContractId, contractId)
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java
@@ -23,6 +23,16 @@
@Mapper
public interface CrmReceivablePlanMapper extends BaseMapperX<CrmReceivablePlanDO> {
    /**
     * æŸ¥è¯¢å¼€ç¥¨å•生成的收款节点
     */
    default java.util.List<CrmReceivablePlanDO> selectListByInvoiceId(Long invoiceId) {
        return selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<CrmReceivablePlanDO>()
                .eq(CrmReceivablePlanDO::getInvoiceId, invoiceId)
                .orderByAsc(CrmReceivablePlanDO::getStage)
                .orderByAsc(CrmReceivablePlanDO::getId));
    }
    default CrmReceivablePlanDO selectMaxPeriodByContractId(Long contractId) {
        return selectOne(new MPJLambdaWrapperX<CrmReceivablePlanDO>()
                .eq(CrmReceivablePlanDO::getContractId, contractId)
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -49,6 +49,11 @@
    ErrorCode RECEIVABLE_DELETE_FAIL_IS_APPROVE = new ErrorCode(1_020_004_008, "删除回款失败,原因:回款审批已通过");
    ErrorCode RECEIVABLE_APPROVE_FAIL = new ErrorCode(1_020_004_009, "审核失败,只有未审核的回款才能审核");
    ErrorCode RECEIVABLE_PROCESS_FAIL = new ErrorCode(1_020_004_010, "反审核失败,只有已审核的回款才能反审核");
    ErrorCode RECEIVABLE_ITEM_BIZ_TYPE_INVALID = new ErrorCode(1_020_004_011, "核销明细校验失败,原因:第 {} è¡Œçš„业务类型[{}]不正确,可选 20销售订单、21销售出库、23开票。请重新选择");
    ErrorCode RECEIVABLE_ITEM_BIZ_NOT_EXISTS = new ErrorCode(1_020_004_012, "核销明细校验失败,原因:第 {} è¡Œé€‰æ‹©çš„{}[{}]不存在。请重新选择关联单据");
    ErrorCode RECEIVABLE_ITEM_PRICE_INVALID = new ErrorCode(1_020_004_013, "核销明细校验失败,原因:第 {} è¡Œ{}[{}]的本次核销金额为 {},必须大于 0。请修改该行核销金额");
    ErrorCode RECEIVABLE_ITEM_SUM_MISMATCH = new ErrorCode(1_020_004_014, "核销明细校验失败,原因:核销明细合计 {} å…ƒï¼Œä¸Žå›žæ¬¾é‡‘额 {} å…ƒä¸ä¸€è‡´ã€‚请调整明细金额,使两者相等");
    ErrorCode RECEIVABLE_ITEM_PRICE_EXCEEDS = new ErrorCode(1_020_004_015, "核销明细校验失败,原因:第 {} è¡Œ{}[{}]本次核销 {} å…ƒï¼Œè¶…出该单据剩余应收 {} å…ƒï¼ˆåº”æ”¶ {} å…ƒï¼Œå·²æ ¸é”€ {} å…ƒï¼‰ã€‚请调小该行金额,或改选其他单据");
    // ========== å›žæ¬¾è®¡åˆ’ 1-020-005-000 ==========
    ErrorCode RECEIVABLE_PLAN_NOT_EXISTS = new ErrorCode(1_020_005_000, "回款计划不存在");
@@ -135,6 +140,22 @@
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_NOT_ATTACHMENT = new ErrorCode(1_020_016_010, "创建回款失败,原因:关联的开票未上传发票附件");
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH = new ErrorCode(1_020_016_011, "创建回款失败,原因:关联的开票与所选合同不匹配");
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_PRICE_EXCEEDS = new ErrorCode(1_020_016_012, "创建回款失败,原因:回款金额超出该开票剩余可回款金额,目前可回款:{} å…ƒ");
    ErrorCode INVOICE_CREATE_FAIL_SALE_ORDER_NOT_EXISTS = new ErrorCode(1_020_016_013, "创建开票失败,原因:销售订单[{}]不存在。请在开票单选择已审核的销售订单,或先到「销售订单」中创建并审核该订单");
    ErrorCode INVOICE_CREATE_FAIL_SALE_ORDER_NOT_APPROVE = new ErrorCode(1_020_016_014, "创建开票失败,原因:销售订单[{}]当前状态为【{}】,只有「已审核」的销售订单才能开票。请先完成该订单审核,或改选其他已审核订单");
    ErrorCode INVOICE_CREATE_FAIL_SALE_ORDER_MISMATCH = new ErrorCode(1_020_016_015, "创建开票失败,原因:销售订单[{}]的客户或合同与当前开票单不一致(订单客户={}、订单合同={};开票客户={}、开票合同={})。请改选与当前合同、客户一致的销售订单");
    ErrorCode INVOICE_CREATE_FAIL_SALE_OUT_NOT_EXISTS = new ErrorCode(1_020_016_016, "创建开票失败,原因:出库单(ID={})不存在或已删除。请重新选择该销售订单下的出库单");
    ErrorCode INVOICE_CREATE_FAIL_SALE_OUT_NOT_FINISHED = new ErrorCode(1_020_016_017, "创建开票失败,原因:出库单[{}]当前状态为【{}】,只有「已完成」的出库单才能开票。请等待出库完成后再开票,或改选其他已完成出库单");
    ErrorCode INVOICE_CREATE_FAIL_SALE_OUT_MISMATCH_ORDER = new ErrorCode(1_020_016_018, "创建开票失败,原因:出库单[{}]不属于所选销售订单[{}](出库单实际订单={})。请改选该销售订单下的出库单");
    ErrorCode INVOICE_CREATE_FAIL_SALE_OUT_MISMATCH_CUSTOMER = new ErrorCode(1_020_016_019, "创建开票失败,原因:出库单[{}]的客户(ID={})与开票客户(ID={})不一致。请改选当前客户名下的出库单");
    ErrorCode INVOICE_CREATE_FAIL_PRICE_EXCEEDS_SALE_ORDER = new ErrorCode(1_020_016_020, "创建开票失败,原因:开票金额超出销售订单[{}]剩余可开票金额 {} å…ƒï¼ˆè®¢å•金额 {} å…ƒï¼Œå·²å¼€ç¥¨ {} å…ƒï¼‰ã€‚请调整开票金额,或先核对已开票金额");
    ErrorCode INVOICE_CREATE_FAIL_PRICE_EXCEEDS_SALE_OUT = new ErrorCode(1_020_016_021, "创建开票失败,原因:开票金额超出出库单[{}]剩余可开票金额 {} å…ƒï¼ˆå‡ºåº“金额 {} å…ƒï¼Œå·²å¼€ç¥¨ {} å…ƒï¼‰ã€‚请调整开票金额,或改选其他出库单");
    ErrorCode INVOICE_ENTRY_FAIL_NOT_APPROVE = new ErrorCode(1_020_016_022, "录入发票失败,原因:开票单[{}]当前审批状态为【{}】,只有「审批通过」的开票申请才能录入发票。请先完成开票申请审批");
    ErrorCode INVOICE_ENTRY_FAIL_INVOICE_NO_EXISTS = new ErrorCode(1_020_016_023, "录入发票失败,原因:发票号码[{}]已被开票单[{}]使用。请核对发票号码,或到该开票单中查看");
    ErrorCode INVOICE_PAYMENT_NODE_INVALID = new ErrorCode(1_020_016_024, "收款节点校验失败,原因:第 {} è¡Œé˜¶æ®µ[{}]的金额为 {},必须大于 0。请修改该节点金额");
    ErrorCode INVOICE_PAYMENT_NODE_TIME_REQUIRED = new ErrorCode(1_020_016_025, "收款节点校验失败,原因:阶段[{}]未选择计划回款时间。请为该节点选择时间节点");
    ErrorCode INVOICE_PAYMENT_NODE_STAGE_INVALID = new ErrorCode(1_020_016_026, "收款节点校验失败,原因:第 {} è¡Œçš„阶段值[{}]不正确,可选 1合同款、2预付款、3到货款、4验收款、5质保金。请重新选择");
    ErrorCode INVOICE_PAYMENT_NODE_STAGE_DUPLICATE = new ErrorCode(1_020_016_027, "收款节点校验失败,原因:阶段[{}]重复出现。同一阶段只能填写一个节点,请合并或删除重复行");
    ErrorCode INVOICE_PAYMENT_NODE_ALREADY_RECEIPTED = new ErrorCode(1_020_016_028, "保存收款节点失败,原因:阶段[{}]已有回款记录(已回款 {} å…ƒï¼‰ï¼Œä¸èƒ½åˆ é™¤æˆ–修改。请先在「回款管理」中删除对应回款,或保留该节点");
    ErrorCode CRM_SALES_TARGET_NOT_EXISTS = new ErrorCode(1_020_017_000, "销售目标不存在");
    ErrorCode CRM_SALES_TARGET_PERIOD_VALUE_INVALID = new ErrorCode(1_020_017_001, "周期值【{}】格式不正确,应为 {}");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/LogRecordConstants.java
@@ -175,5 +175,7 @@
    String CRM_INVOICE_SUBMIT_SUCCESS = "提交编号为【{{#invoiceNo}}】的开票审批成功";
    String CRM_INVOICE_IMPORT_SUB_TYPE = "导入开票";
    String CRM_INVOICE_IMPORT_SUCCESS = "导入了开票:{{#importSummary}}";
    String CRM_INVOICE_ENTRY_SUB_TYPE = "录入发票";
    String CRM_INVOICE_ENTRY_SUCCESS = "为编号为【{{#invoiceNo}}】的开票录入发票【{{#entryInvoiceNo}}】";
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/receivable/CrmPaymentStageEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.crm.enums.receivable;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * CRM æ”¶æ¬¾é˜¶æ®µæžšä¸¾
 *
 * ç”¨äºŽå¼€ç¥¨å½•入发票时,按销售订单拆分合同款、预付款、到货款、验收款等收款节点。
 *
 * @author è¶…级管理员
 */
@RequiredArgsConstructor
@Getter
public enum CrmPaymentStageEnum implements ArrayValuable<Integer> {
    CONTRACT(1, "合同款"),
    PREPAYMENT(2, "预付款"),
    DELIVERY(3, "到货款"),
    ACCEPTANCE(4, "验收款"),
    WARRANTY(5, "质保金");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(CrmPaymentStageEnum::getStage).toArray(Integer[]::new);
    /**
     * é˜¶æ®µ
     */
    private final Integer stage;
    /**
     * åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static CrmPaymentStageEnum valueOf(Integer stage) {
        return Arrays.stream(values()).filter(item -> item.getStage().equals(stage)).findFirst().orElse(null);
    }
    public static String getNameByStage(Integer stage) {
        CrmPaymentStageEnum item = valueOf(stage);
        return item == null ? String.valueOf(stage) : item.getName();
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/receivable/CrmReceivableBizTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.crm.enums.receivable;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * CRM å›žæ¬¾æ ¸é”€ä¸šåŠ¡ç±»åž‹æžšä¸¾
 *
 * æ•°å€¼ä¸Ž ERP {@code ErpBizTypeEnum} çš„销售侧保持一致,开票使用 23。
 *
 * @author è¶…级管理员
 */
@RequiredArgsConstructor
@Getter
public enum CrmReceivableBizTypeEnum implements ArrayValuable<Integer> {
    SALE_ORDER(20, "销售订单"),
    SALE_OUT(21, "销售出库"),
    SALE_INVOICE(23, "开票");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(CrmReceivableBizTypeEnum::getType).toArray(Integer[]::new);
    /**
     * ç±»åž‹
     */
    private final Integer type;
    /**
     * åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static CrmReceivableBizTypeEnum valueOf(Integer type) {
        return Arrays.stream(values()).filter(item -> item.getType().equals(type)).findFirst().orElse(null);
    }
    public static String getNameByType(Integer type) {
        CrmReceivableBizTypeEnum item = valueOf(type);
        return item == null ? String.valueOf(type) : item.getName();
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/permission/core/aop/CrmPermissionAspect.java
@@ -103,13 +103,16 @@
            }
        }
        // 2. åªè€ƒè™‘自身的权限
        // 2. åªè€ƒè™‘自身的权限;离职交接完成后,交接人继承离职人对该数据的权限
        Long userId = getUserId();
        CrmPermissionDO userPermission = CollUtil.findOne(bizPermissions, permission -> ObjUtil.equal(permission.getUserId(), userId));
        if (userPermission != null) {
            if (isUserPermissionValid(userPermission, permissionLevel)) {
        CrmPermissionDO userPermission = null;
        for (Long ownerUserId : CrmPermissionUtils.getOwnerUserIds(userId)) {
            CrmPermissionDO candidate = CollUtil.findOne(bizPermissions,
                    permission -> ObjUtil.equal(permission.getUserId(), ownerUserId));
            if (candidate != null && isUserPermissionValid(candidate, permissionLevel)) {
                return;
            }
            userPermission = candidate != null ? candidate : userPermission;
        }
        // 3. å•†æœºç‰¹æ®Šå¤„理:检查客户团队权限
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/finance/CrmFinanceReportService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.crm.service.finance;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmCustomerStatementRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmFinanceTrendRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmShippedNotInvoicedRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmUnwrittenOffRespVO;
import java.util.List;
/**
 * CRM è´¢åŠ¡å¯¹è´¦ä¸ŽæŠ¥è¡¨ Service æŽ¥å£
 *
 * æ•°æ®å£å¾„:
 * - è®¢å•金额:ERP é”€å”®è®¢å• totalPrice(仅已审核订单)
 * - å‡ºåº“金额:MES é”€å”®å‡ºåº“单 totalPrice(仅已完成)
 * - å¼€ç¥¨é‡‘额:CRM å¼€ç¥¨ price(仅审批通过)
 * - å›žæ¬¾é‡‘额:CRM å›žæ¬¾ price(仅审批通过)
 *
 * @author è¶…级管理员
 */
public interface CrmFinanceReportService {
    /**
     * èŽ·å¾—å®¢æˆ·åº”æ”¶å¯¹è´¦å•
     *
     * @param customerId å®¢æˆ·ç¼–号
     * @return å¯¹è´¦å•
     */
    CrmCustomerStatementRespVO getCustomerStatement(Long customerId);
    /**
     * èŽ·å¾—å·²å‘è´§æœªå¼€ç¥¨æ˜Žç»†
     *
     * @param customerId å®¢æˆ·ç¼–号,为空时统计全部客户
     * @return æ˜Žç»†åˆ—表
     */
    List<CrmShippedNotInvoicedRespVO> getShippedNotInvoicedList(Long customerId);
    /**
     * èŽ·å¾—æœªæ ¸é”€åº”æ”¶æ˜Žç»†
     *
     * @param customerId å®¢æˆ·ç¼–号,为空时统计全部客户
     * @return æ˜Žç»†åˆ—表
     */
    List<CrmUnwrittenOffRespVO> getUnwrittenOffList(Long customerId);
    /**
     * èŽ·å¾—æœ€è¿‘è‹¥å¹²ä¸ªæœˆçš„å¼€ç¥¨ã€å›žæ¬¾è¶‹åŠ¿
     *
     * @param months æœˆä»½æ•°é‡
     * @return è¶‹åŠ¿åˆ—è¡¨ï¼ˆæŒ‰æœˆä»½å‡åºï¼‰
     */
    List<CrmFinanceTrendRespVO> getMonthlyTrend(Integer months);
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/finance/CrmFinanceReportServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,434 @@
package cn.iocoder.yudao.module.crm.service.finance;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmCustomerStatementRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmFinanceTrendRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmShippedNotInvoicedRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmUnwrittenOffRespVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableItemDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.dal.mysql.invoice.CrmInvoiceMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableItemMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivablePlanMapper;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableBizTypeEnum;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
import cn.iocoder.yudao.module.mes.api.productsales.MesWmProductSalesApi;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
/**
 * CRM è´¢åŠ¡å¯¹è´¦ä¸ŽæŠ¥è¡¨ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class CrmFinanceReportServiceImpl implements CrmFinanceReportService {
    private static final Integer ERP_STATUS_APPROVE = 20;
    private static final Integer MES_STATUS_FINISHED = 4;
    private static final Integer AUDIT_APPROVE = CrmAuditStatusEnum.APPROVE.getStatus();
    private static final Integer PLAN_STATUS_RECEIPTED = 20;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MesWmProductSalesApi productSalesApi;
    @Resource
    private CrmInvoiceMapper invoiceMapper;
    @Resource
    private CrmReceivableMapper receivableMapper;
    @Resource
    private CrmReceivableItemMapper receivableItemMapper;
    @Resource
    private CrmReceivablePlanMapper receivablePlanMapper;
    @Resource
    private CrmCustomerService customerService;
    @Override
    public CrmCustomerStatementRespVO getCustomerStatement(Long customerId) {
        List<ErpSaleOrderRespDTO> orders = getApprovedOrders(customerId);
        Map<Long, BigDecimal> collectedByOrder = getCollectedAmountByOrder(orders, customerId);
        CrmCustomerStatementRespVO result = new CrmCustomerStatementRespVO();
        result.setCustomerId(customerId);
        result.setOrderCount(orders.size());
        result.setOrderTotalAmount(BigDecimal.ZERO);
        result.setShippedAmount(BigDecimal.ZERO);
        result.setInvoicedAmount(BigDecimal.ZERO);
        result.setCollectedAmount(BigDecimal.ZERO);
        result.setShippedNotInvoicedAmount(BigDecimal.ZERO);
        result.setOutstandingAmount(BigDecimal.ZERO);
        result.setNextMonthPlanAmount(getNextMonthPlanAmount(customerId));
        List<CrmCustomerStatementRespVO.OrderRow> rows = new ArrayList<>(orders.size());
        for (ErpSaleOrderRespDTO order : orders) {
            CrmCustomerStatementRespVO.OrderRow row = new CrmCustomerStatementRespVO.OrderRow();
            row.setSaleOrderId(order.getId());
            row.setSaleOrderNo(order.getNo());
            row.setContractId(order.getContractId());
            row.setOrderAmount(zeroIfNull(order.getTotalPrice()));
            row.setShippedAmount(getShippedAmount(order.getId()));
            row.setInvoicedAmount(getInvoicedAmount(order.getId()));
            row.setCollectedAmount(collectedByOrder.getOrDefault(order.getId(), BigDecimal.ZERO));
            row.setShippedNotInvoicedAmount(maxZero(row.getShippedAmount().subtract(row.getInvoicedAmount())));
            row.setOutstandingAmount(maxZero(row.getOrderAmount().subtract(row.getCollectedAmount())));
            rows.add(row);
            result.setOrderTotalAmount(result.getOrderTotalAmount().add(row.getOrderAmount()));
            result.setShippedAmount(result.getShippedAmount().add(row.getShippedAmount()));
            result.setInvoicedAmount(result.getInvoicedAmount().add(row.getInvoicedAmount()));
            result.setCollectedAmount(result.getCollectedAmount().add(row.getCollectedAmount()));
            result.setShippedNotInvoicedAmount(result.getShippedNotInvoicedAmount().add(row.getShippedNotInvoicedAmount()));
            result.setOutstandingAmount(result.getOutstandingAmount().add(row.getOutstandingAmount()));
        }
        result.setOrders(rows);
        if (customerId != null) {
            CrmCustomerDO customer = customerService.getCustomer(customerId);
            result.setCustomerName(customer == null ? null : customer.getName());
        }
        return result;
    }
    @Override
    public List<CrmShippedNotInvoicedRespVO> getShippedNotInvoicedList(Long customerId) {
        List<ErpSaleOrderRespDTO> orders = getApprovedOrders(customerId);
        if (CollUtil.isEmpty(orders)) {
            return Collections.emptyList();
        }
        Map<Long, ErpSaleOrderRespDTO> orderMap = new HashMap<>();
        orders.forEach(order -> orderMap.put(order.getId(), order));
        Map<Long, String> customerNameMap = getCustomerNameMap(orders.stream()
                .map(ErpSaleOrderRespDTO::getCustomerId).filter(Objects::nonNull).toList());
        List<CrmShippedNotInvoicedRespVO> result = new ArrayList<>();
        for (ErpSaleOrderRespDTO order : orders) {
            for (MesWmProductSalesRespDTO saleOut : getFinishedSaleOuts(order.getId())) {
                BigDecimal outAmount = zeroIfNull(saleOut.getTotalPrice());
                BigDecimal invoicedAmount = sumInvoicePrice(invoiceMapper.selectListBySaleOutIdAndStatus(
                        saleOut.getId(), List.of(AUDIT_APPROVE)));
                BigDecimal remainAmount = outAmount.subtract(invoicedAmount);
                if (remainAmount.compareTo(BigDecimal.ZERO) <= 0) {
                    continue;
                }
                CrmShippedNotInvoicedRespVO vo = new CrmShippedNotInvoicedRespVO();
                vo.setSaleOutId(saleOut.getId());
                vo.setSaleOutCode(saleOut.getCode());
                vo.setSalesDate(saleOut.getSalesDate());
                vo.setSaleOrderId(order.getId());
                vo.setSaleOrderNo(order.getNo());
                vo.setCustomerId(order.getCustomerId());
                vo.setCustomerName(customerNameMap.get(order.getCustomerId()));
                vo.setOutAmount(outAmount);
                vo.setInvoicedAmount(invoicedAmount);
                vo.setRemainInvoiceAmount(remainAmount);
                result.add(vo);
            }
        }
        return result;
    }
    @Override
    public List<CrmUnwrittenOffRespVO> getUnwrittenOffList(Long customerId) {
        List<CrmInvoiceDO> invoices = invoiceMapper.selectList(new LambdaQueryWrapperX<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getAuditStatus, AUDIT_APPROVE)
                .eqIfPresent(CrmInvoiceDO::getCustomerId, customerId)
                .orderByAsc(CrmInvoiceDO::getInvoiceTime));
        if (CollUtil.isEmpty(invoices)) {
            return Collections.emptyList();
        }
        Map<Long, BigDecimal> collectedMap = getCollectedAmountByInvoice(customerId);
        Map<Long, String> customerNameMap = getCustomerNameMap(invoices.stream()
                .map(CrmInvoiceDO::getCustomerId).filter(Objects::nonNull).toList());
        List<CrmUnwrittenOffRespVO> result = new ArrayList<>();
        LocalDate today = LocalDate.now();
        for (CrmInvoiceDO invoice : invoices) {
            BigDecimal invoiceAmount = zeroIfNull(invoice.getPrice());
            BigDecimal collectedAmount = collectedMap.getOrDefault(invoice.getId(), BigDecimal.ZERO);
            BigDecimal remainAmount = invoiceAmount.subtract(collectedAmount);
            if (remainAmount.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            CrmUnwrittenOffRespVO vo = new CrmUnwrittenOffRespVO();
            vo.setInvoiceId(invoice.getId());
            vo.setInvoiceNo(invoice.getNo());
            vo.setTaxInvoiceNo(invoice.getInvoiceNo());
            vo.setCustomerId(invoice.getCustomerId());
            vo.setCustomerName(customerNameMap.get(invoice.getCustomerId()));
            vo.setContractId(invoice.getContractId());
            vo.setSaleOrderId(invoice.getSaleOrderId());
            vo.setInvoiceTime(invoice.getInvoiceTime());
            vo.setInvoiceAmount(invoiceAmount);
            vo.setCollectedAmount(collectedAmount);
            vo.setRemainAmount(remainAmount);
            long agingDays = invoice.getInvoiceTime() == null ? 0
                    : Math.max(0, ChronoUnit.DAYS.between(invoice.getInvoiceTime().toLocalDate(), today));
            vo.setAgingDays(agingDays);
            vo.setAgingBucket(agingBucket(agingDays));
            result.add(vo);
        }
        return result;
    }
    @Override
    public List<CrmFinanceTrendRespVO> getMonthlyTrend(Integer months) {
        int monthCount = months == null ? 6 : Math.min(Math.max(months, 1), 36);
        YearMonth startMonth = YearMonth.now().minusMonths(monthCount - 1L);
        LocalDateTime beginTime = startMonth.atDay(1).atStartOfDay();
        LocalDateTime endTime = YearMonth.now().plusMonths(1).atDay(1).atStartOfDay();
        List<CrmInvoiceDO> invoices = invoiceMapper.selectList(new LambdaQueryWrapperX<CrmInvoiceDO>()
                .eq(CrmInvoiceDO::getAuditStatus, AUDIT_APPROVE)
                .between(CrmInvoiceDO::getInvoiceTime, beginTime, endTime));
        List<CrmReceivableDO> receivables = receivableMapper.selectList(new LambdaQueryWrapperX<CrmReceivableDO>()
                .eq(CrmReceivableDO::getAuditStatus, AUDIT_APPROVE)
                .between(CrmReceivableDO::getReturnTime, beginTime, endTime));
        Map<String, BigDecimal> invoicedMap = new HashMap<>();
        Map<String, BigDecimal> collectedMap = new HashMap<>();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
        invoices.forEach(invoice -> {
            if (invoice.getInvoiceTime() != null) {
                mergeAmount(invoicedMap, invoice.getInvoiceTime().format(formatter), zeroIfNull(invoice.getPrice()));
            }
        });
        receivables.forEach(receivable -> {
            if (receivable.getReturnTime() != null) {
                mergeAmount(collectedMap, receivable.getReturnTime().format(formatter), zeroIfNull(receivable.getPrice()));
            }
        });
        List<CrmFinanceTrendRespVO> result = new ArrayList<>(monthCount);
        for (int i = 0; i < monthCount; i++) {
            String month = startMonth.plusMonths(i).format(formatter);
            CrmFinanceTrendRespVO vo = new CrmFinanceTrendRespVO();
            vo.setMonth(month);
            vo.setInvoicedAmount(invoicedMap.getOrDefault(month, BigDecimal.ZERO));
            vo.setCollectedAmount(collectedMap.getOrDefault(month, BigDecimal.ZERO));
            result.add(vo);
        }
        return result;
    }
    // ==================== æ•°æ®æŸ¥è¯¢ä¸Žè®¡ç®— ====================
    private List<ErpSaleOrderRespDTO> getApprovedOrders(Long customerId) {
        List<ErpSaleOrderRespDTO> orders = customerId == null
                ? saleOrderApi.getSaleOrderList().getCheckedData()
                : saleOrderApi.getSaleOrderListByCustomerId(customerId).getCheckedData();
        if (CollUtil.isEmpty(orders)) {
            return Collections.emptyList();
        }
        return orders.stream()
                .filter(order -> ObjectUtilEqual(order.getStatus(), ERP_STATUS_APPROVE))
                .toList();
    }
    private List<MesWmProductSalesRespDTO> getFinishedSaleOuts(Long saleOrderId) {
        List<MesWmProductSalesRespDTO> saleOuts = productSalesApi.getProductSalesListBySaleOrderId(saleOrderId);
        if (CollUtil.isEmpty(saleOuts)) {
            return Collections.emptyList();
        }
        return saleOuts.stream().filter(saleOut -> ObjectUtilEqual(saleOut.getStatus(), MES_STATUS_FINISHED)).toList();
    }
    private BigDecimal getShippedAmount(Long saleOrderId) {
        return getFinishedSaleOuts(saleOrderId).stream()
                .map(saleOut -> zeroIfNull(saleOut.getTotalPrice()))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    private BigDecimal getInvoicedAmount(Long saleOrderId) {
        return sumInvoicePrice(invoiceMapper.selectListBySaleOrderIdAndStatus(saleOrderId, List.of(AUDIT_APPROVE)));
    }
    /**
     * è®¡ç®—每张销售订单已回款金额
     *
     * ä¼˜å…ˆå–核销明细(销售订单 / å¼€ç¥¨ï¼‰ï¼ŒåŽ†å²å›žæ¬¾æ²¡æœ‰æ ¸é”€æ˜Žç»†æ—¶æŒ‰å›žæ¬¾å…³è”çš„å¼€ç¥¨æŠ˜ç®—åˆ°è®¢å•ã€‚
     */
    private Map<Long, BigDecimal> getCollectedAmountByOrder(List<ErpSaleOrderRespDTO> orders, Long customerId) {
        Map<Long, BigDecimal> result = new HashMap<>();
        Map<Long, ErpSaleOrderRespDTO> orderMap = new HashMap<>();
        orders.forEach(order -> orderMap.put(order.getId(), order));
        if (orderMap.isEmpty()) {
            return result;
        }
        // 1. å·²å®¡æ ¸å›žæ¬¾
        List<CrmReceivableDO> receivables = customerId == null
                ? receivableMapper.selectList(new LambdaQueryWrapperX<CrmReceivableDO>()
                .eq(CrmReceivableDO::getAuditStatus, AUDIT_APPROVE))
                : receivableMapper.selectListByCustomerId(customerId).stream()
                .filter(receivable -> ObjectUtilEqual(receivable.getAuditStatus(), AUDIT_APPROVE)).toList();
        if (CollUtil.isEmpty(receivables)) {
            return result;
        }
        Map<Long, CrmReceivableDO> receivableMap = new HashMap<>();
        receivables.forEach(receivable -> receivableMap.put(receivable.getId(), receivable));
        // 2. æ ¸é”€æ˜Žç»†
        List<CrmReceivableItemDO> items = receivableItemMapper.selectListByReceivableIds(receivableMap.keySet());
        Set<Long> receivablesWithItems = new HashSet<>();
        Set<Long> invoiceIds = new HashSet<>();
        items.forEach(item -> {
            receivablesWithItems.add(item.getReceivableId());
            if (CrmReceivableBizTypeEnum.SALE_INVOICE.getType().equals(item.getBizType()) && item.getBizId() != null) {
                invoiceIds.add(item.getBizId());
            }
        });
        receivables.forEach(receivable -> {
            if (receivable.getInvoiceId() != null) {
                invoiceIds.add(receivable.getInvoiceId());
            }
        });
        Map<Long, Long> invoiceOrderMap = new HashMap<>();
        if (CollUtil.isNotEmpty(invoiceIds)) {
            invoiceMapper.selectByIds(invoiceIds).forEach(invoice -> {
                if (invoice.getSaleOrderId() != null) {
                    invoiceOrderMap.put(invoice.getId(), invoice.getSaleOrderId());
                }
            });
        }
        for (CrmReceivableItemDO item : items) {
            if (!receivableMap.containsKey(item.getReceivableId())) {
                continue;
            }
            Long orderId = null;
            if (CrmReceivableBizTypeEnum.SALE_ORDER.getType().equals(item.getBizType())) {
                orderId = item.getBizId();
            } else if (CrmReceivableBizTypeEnum.SALE_INVOICE.getType().equals(item.getBizType())) {
                orderId = invoiceOrderMap.get(item.getBizId());
            }
            if (orderId != null && orderMap.containsKey(orderId)) {
                result.merge(orderId, zeroIfNull(item.getReceiptPrice()), BigDecimal::add);
            }
        }
        // 3. åŽ†å²å›žæ¬¾ï¼šæ²¡æœ‰æ ¸é”€æ˜Žç»†æ—¶ï¼ŒæŒ‰å›žæ¬¾å…³è”å¼€ç¥¨æŠ˜ç®—åˆ°è®¢å•ï¼ˆåªå¤„ç†è®¢å•èŒƒå›´å†…çš„å¼€ç¥¨ï¼‰
        for (CrmReceivableDO receivable : receivables) {
            if (receivablesWithItems.contains(receivable.getId()) || receivable.getInvoiceId() == null) {
                continue;
            }
            Long orderId = invoiceOrderMap.get(receivable.getInvoiceId());
            if (orderId != null && orderMap.containsKey(orderId)) {
                result.merge(orderId, zeroIfNull(receivable.getPrice()), BigDecimal::add);
            }
        }
        return result;
    }
    /**
     * è®¡ç®—每张开票已回款金额(仅已审核回款)
     *
     * æœ‰æ ¸é”€æ˜Žç»†æ—¶æŒ‰æ˜Žç»†å½’集到开票;历史回款按 invoiceId ç›´æŽ¥å½’集。
     */
    private Map<Long, BigDecimal> getCollectedAmountByInvoice(Long customerId) {
        Map<Long, BigDecimal> result = new HashMap<>();
        List<CrmReceivableDO> receivables = customerId == null
                ? receivableMapper.selectList(new LambdaQueryWrapperX<CrmReceivableDO>()
                .eq(CrmReceivableDO::getAuditStatus, AUDIT_APPROVE))
                : receivableMapper.selectListByCustomerId(customerId).stream()
                .filter(receivable -> ObjectUtilEqual(receivable.getAuditStatus(), AUDIT_APPROVE)).toList();
        if (CollUtil.isEmpty(receivables)) {
            return result;
        }
        Map<Long, CrmReceivableDO> receivableMap = new HashMap<>();
        receivables.forEach(receivable -> receivableMap.put(receivable.getId(), receivable));
        List<CrmReceivableItemDO> items = receivableItemMapper.selectListByReceivableIds(receivableMap.keySet());
        Set<Long> receivablesWithItems = new HashSet<>();
        for (CrmReceivableItemDO item : items) {
            receivablesWithItems.add(item.getReceivableId());
            if (CrmReceivableBizTypeEnum.SALE_INVOICE.getType().equals(item.getBizType()) && item.getBizId() != null) {
                result.merge(item.getBizId(), zeroIfNull(item.getReceiptPrice()), BigDecimal::add);
            }
        }
        for (CrmReceivableDO receivable : receivables) {
            if (receivablesWithItems.contains(receivable.getId()) || receivable.getInvoiceId() == null) {
                continue;
            }
            result.merge(receivable.getInvoiceId(), zeroIfNull(receivable.getPrice()), BigDecimal::add);
        }
        return result;
    }
    private BigDecimal getNextMonthPlanAmount(Long customerId) {
        YearMonth nextMonth = YearMonth.now().plusMonths(1);
        LocalDateTime beginTime = nextMonth.atDay(1).atStartOfDay();
        LocalDateTime endTime = nextMonth.plusMonths(1).atDay(1).atStartOfDay();
        List<CrmReceivablePlanDO> plans = receivablePlanMapper.selectList(
                new LambdaQueryWrapperX<CrmReceivablePlanDO>()
                        .eqIfPresent(CrmReceivablePlanDO::getCustomerId, customerId)
                        .ge(CrmReceivablePlanDO::getReturnTime, beginTime)
                        .lt(CrmReceivablePlanDO::getReturnTime, endTime));
        return plans.stream()
                .filter(plan -> !PLAN_STATUS_RECEIPTED.equals(plan.getStatus()))
                .map(plan -> zeroIfNull(plan.getPrice()))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    private Map<Long, String> getCustomerNameMap(Collection<Long> customerIds) {
        if (CollUtil.isEmpty(customerIds)) {
            return Collections.emptyMap();
        }
        Map<Long, CrmCustomerDO> customerMap = customerService.getCustomerMap(customerIds);
        Map<Long, String> result = new HashMap<>();
        customerMap.forEach((id, customer) -> result.put(id, customer.getName()));
        return result;
    }
    private static BigDecimal sumInvoicePrice(List<CrmInvoiceDO> invoices) {
        if (CollUtil.isEmpty(invoices)) {
            return BigDecimal.ZERO;
        }
        return invoices.stream().map(invoice -> zeroIfNull(invoice.getPrice())).reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    private static void mergeAmount(Map<String, BigDecimal> map, String key, BigDecimal amount) {
        map.merge(key, amount, BigDecimal::add);
    }
    private static String agingBucket(long days) {
        if (days <= 30) {
            return "0-30天";
        }
        if (days <= 60) {
            return "31-60天";
        }
        if (days <= 90) {
            return "61-90天";
        }
        return "90天以上";
    }
    private static boolean ObjectUtilEqual(Object left, Object right) {
        return Objects.equals(left, right);
    }
    private static BigDecimal zeroIfNull(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private static BigDecimal maxZero(BigDecimal value) {
        return value.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : value;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java
@@ -1,11 +1,14 @@
package cn.iocoder.yudao.module.crm.service.invoice;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceEntryReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportExcelVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoicePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaleOutRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import jakarta.validation.Valid;
import java.math.BigDecimal;
@@ -46,6 +49,32 @@
    void updateInvoice(@Valid CrmInvoiceSaveReqVO updateReqVO);
    /**
     * å¼€ç¥¨ç”³è¯·å½•入发票
     *
     * ä»…审批通过的开票申请可录入;录入后开票进入「已录入」状态,可被回款单关联。
     *
     * @param reqVO å½•入信息
     */
    void entryInvoice(@Valid CrmInvoiceEntryReqVO reqVO);
    /**
     * èŽ·å¾—å¼€ç¥¨å¯é€‰çš„é”€å”®å‡ºåº“å•
     *
     * @param saleOrderId      é”€å”®è®¢å•编号
     * @param excludeInvoiceId éœ€æŽ’除的开票编号,可为空
     * @return å‡ºåº“单列表(含已开票、剩余可开票金额)
     */
    List<CrmInvoiceSaleOutRespVO> getSaleOutListForInvoice(Long saleOrderId, Long excludeInvoiceId);
    /**
     * èŽ·å¾—å¼€ç¥¨å•å¯¹åº”çš„æ”¶æ¬¾èŠ‚ç‚¹
     *
     * @param invoiceId å¼€ç¥¨ç¼–号
     * @return æ”¶æ¬¾èŠ‚ç‚¹åˆ—è¡¨
     */
    List<CrmReceivablePlanDO> getPaymentNodeListByInvoiceId(Long invoiceId);
    /**
     * æ›´æ–°å¼€ç¥¨æµç¨‹å®¡æ‰¹ç»“æžœ
     *
     * @param id        å¼€ç¥¨ç¼–号
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
@@ -14,17 +14,26 @@
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceEntryReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportExcelVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoicePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaleOutRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.dal.mysql.invoice.CrmInvoiceMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivablePlanMapper;
import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.enums.receivable.CrmPaymentStageEnum;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
import cn.iocoder.yudao.module.mes.api.productsales.MesWmProductSalesApi;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO;
import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService;
@@ -46,9 +55,12 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@@ -77,8 +89,36 @@
     */
    private static final String INVOICE_RECORD_TYPE = "crm_invoice";
    /**
     * ERP é”€å”®è®¢å•审核通过状态,对应 cn.iocoder.yudao.module.erp.enums.ErpAuditStatus#APPROVE
     */
    private static final Integer ERP_SALE_ORDER_STATUS_APPROVE = 20;
    /**
     * MES é”€å”®å‡ºåº“单已完成状态,对应 cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum#FINISHED
     */
    private static final Integer MES_PRODUCT_SALES_STATUS_FINISHED = 4;
    /**
     * MES é”€å”®å‡ºåº“单已取消状态
     */
    private static final Integer MES_PRODUCT_SALES_STATUS_CANCELED = 5;
    /**
     * å¼€ç¥¨å ç”¨é¢åº¦çš„审批状态:审批中 + å®¡æ‰¹é€šè¿‡
     */
    private static final List<Integer> INVOICE_OCCUPY_STATUSES = List.of(
            CrmAuditStatusEnum.PROCESS.getStatus(), CrmAuditStatusEnum.APPROVE.getStatus());
    @Resource
    private CrmInvoiceMapper invoiceMapper;
    @Resource
    private CrmReceivablePlanMapper receivablePlanMapper;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MesWmProductSalesApi productSalesApi;
    @Resource
    private CrmNoRedisDAO noRedisDAO;
@@ -100,25 +140,31 @@
    @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_CREATE_SUB_TYPE, bizNo = "{{#invoice.id}}",
            success = CRM_INVOICE_CREATE_SUCCESS)
    public Long createInvoice(CrmInvoiceSaveReqVO createReqVO) {
        // 1.1 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        validateInvoicePriceExceedsLimit(createReqVO);
        // 1.2 æ ¡éªŒå…³è”数据存在
        // 1.1 æ ¡éªŒå…³è”数据存在(合同 -> å®¢æˆ·ï¼‰
        validateRelationDataExists(createReqVO);
        // 1.3 ç”Ÿæˆå¼€ç¥¨ç¼–号
        // 1.2 æ ¡éªŒé”€å”®è®¢å•、销售出库单,并回填出库单号
        validateSaleOrderAndOut(createReqVO);
        // 1.3 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        validateInvoicePriceExceedsLimit(createReqVO);
        // 1.4 ç”Ÿæˆå¼€ç¥¨ç¼–号
        String no = noRedisDAO.generate(CrmNoRedisDAO.INVOICE_PREFIX);
        if (invoiceMapper.selectByNo(no) != null) {
            throw exception(INVOICE_NO_EXISTS);
        }
        // 2.1 æ’入开票
        // 2.1 æ’入开票:已填写发票号码视为已录入发票
        CrmInvoiceDO invoice = BeanUtils.toBean(createReqVO, CrmInvoiceDO.class)
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        applyEntryStatus(invoice, createReqVO.getInvoiceNo(), null);
        invoiceMapper.insert(invoice);
        // 2.2 ç»‘定发票附件
        if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
            storageAttachmentApi.bindAttachments("file", INVOICE_RECORD_TYPE, invoice.getId(), createReqVO.getBlobIds());
        }
        // 2.3 åŒæ­¥æ”¶æ¬¾èŠ‚ç‚¹ï¼ˆåˆåŒæ¬¾/预付款/到货款/验收款)
        syncPaymentNodes(invoice, createReqVO.getPaymentNodes());
        // 3. åˆ›å»ºæ•°æ®æƒé™
        permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_INVOICE.getType())
@@ -390,6 +436,212 @@
        }
    }
    /**
     * æ ¡éªŒé”€å”®è®¢å•、销售出库单,并回填出库单号
     *
     * æ ¡éªŒé¡ºåºï¼šé”€å”®è®¢å•存在 -> å·²å®¡æ ¸ -> å®¢æˆ·/合同一致 -> è®¢å•剩余可开票;
     * å†æ ¡éªŒå‡ºåº“单存在 -> å·²å®Œæˆ -> å±žäºŽè¯¥è®¢å• -> å®¢æˆ·ä¸€è‡´ -> å‡ºåº“单剩余可开票。
     */
    private void validateSaleOrderAndOut(CrmInvoiceSaveReqVO reqVO) {
        if (reqVO.getSaleOrderId() == null) {
            return;
        }
        // 1. é”€å”®è®¢å•
        ErpSaleOrderRespDTO saleOrder = saleOrderApi.getSaleOrder(reqVO.getSaleOrderId()).getCheckedData();
        if (saleOrder == null) {
            throw exception(INVOICE_CREATE_FAIL_SALE_ORDER_NOT_EXISTS, reqVO.getSaleOrderId());
        }
        if (ObjectUtil.notEqual(saleOrder.getStatus(), ERP_SALE_ORDER_STATUS_APPROVE)) {
            throw exception(INVOICE_CREATE_FAIL_SALE_ORDER_NOT_APPROVE, saleOrder.getNo(),
                    erpAuditStatusName(saleOrder.getStatus()));
        }
        if (ObjectUtil.notEqual(saleOrder.getCustomerId(), reqVO.getCustomerId())
                || (saleOrder.getContractId() != null
                && ObjectUtil.notEqual(saleOrder.getContractId(), reqVO.getContractId()))) {
            throw exception(INVOICE_CREATE_FAIL_SALE_ORDER_MISMATCH, saleOrder.getNo(),
                    saleOrder.getCustomerId(), saleOrder.getContractId(), reqVO.getCustomerId(), reqVO.getContractId());
        }
        // 1.1 è®¢å•剩余可开票金额 = è®¢å•金额 - è¯¥è®¢å•已占用开票金额(审批中 + å®¡æ‰¹é€šè¿‡ï¼‰
        BigDecimal orderTotalPrice = zeroIfNull(saleOrder.getTotalPrice());
        BigDecimal orderInvoicedPrice = sumInvoicePrice(invoiceMapper.selectListBySaleOrderIdAndStatus(
                reqVO.getSaleOrderId(), INVOICE_OCCUPY_STATUSES), reqVO.getId());
        BigDecimal orderRemainPrice = orderTotalPrice.subtract(orderInvoicedPrice);
        if (reqVO.getPrice() != null && reqVO.getPrice().compareTo(orderRemainPrice) > 0) {
            throw exception(INVOICE_CREATE_FAIL_PRICE_EXCEEDS_SALE_ORDER, saleOrder.getNo(),
                    formatPrice(orderRemainPrice), formatPrice(orderTotalPrice), formatPrice(orderInvoicedPrice));
        }
        // 2. é”€å”®å‡ºåº“单(可选)
        if (reqVO.getSaleOutId() == null) {
            return;
        }
        MesWmProductSalesRespDTO saleOut = productSalesApi.getProductSales(reqVO.getSaleOutId());
        if (saleOut == null) {
            throw exception(INVOICE_CREATE_FAIL_SALE_OUT_NOT_EXISTS, reqVO.getSaleOutId());
        }
        if (ObjectUtil.notEqual(saleOut.getStatus(), MES_PRODUCT_SALES_STATUS_FINISHED)) {
            throw exception(INVOICE_CREATE_FAIL_SALE_OUT_NOT_FINISHED, saleOut.getCode(),
                    productSalesStatusName(saleOut.getStatus()));
        }
        if (saleOut.getSaleOrderId() != null && ObjectUtil.notEqual(saleOut.getSaleOrderId(), reqVO.getSaleOrderId())) {
            throw exception(INVOICE_CREATE_FAIL_SALE_OUT_MISMATCH_ORDER, saleOut.getCode(),
                    saleOrder.getNo(), saleOut.getSaleOrderId());
        }
        if (saleOut.getClientId() != null && ObjectUtil.notEqual(saleOut.getClientId(), reqVO.getCustomerId())) {
            throw exception(INVOICE_CREATE_FAIL_SALE_OUT_MISMATCH_CUSTOMER, saleOut.getCode(),
                    saleOut.getClientId(), reqVO.getCustomerId());
        }
        // 2.1 å‡ºåº“单剩余可开票金额 = å‡ºåº“金额 - è¯¥å‡ºåº“单已占用开票金额(审批中 + å®¡æ‰¹é€šè¿‡ï¼‰
        BigDecimal saleOutTotalPrice = zeroIfNull(saleOut.getTotalPrice());
        BigDecimal saleOutInvoicedPrice = sumInvoicePrice(invoiceMapper.selectListBySaleOutIdAndStatus(
                reqVO.getSaleOutId(), INVOICE_OCCUPY_STATUSES), reqVO.getId());
        BigDecimal saleOutRemainPrice = saleOutTotalPrice.subtract(saleOutInvoicedPrice);
        if (reqVO.getPrice() != null && reqVO.getPrice().compareTo(saleOutRemainPrice) > 0) {
            throw exception(INVOICE_CREATE_FAIL_PRICE_EXCEEDS_SALE_OUT, saleOut.getCode(),
                    formatPrice(saleOutRemainPrice), formatPrice(saleOutTotalPrice), formatPrice(saleOutInvoicedPrice));
        }
        reqVO.setSaleOutCode(saleOut.getCode());
    }
    private BigDecimal sumInvoicePrice(List<CrmInvoiceDO> invoices, Long excludeInvoiceId) {
        if (CollUtil.isEmpty(invoices)) {
            return BigDecimal.ZERO;
        }
        return invoices.stream()
                .filter(invoice -> excludeInvoiceId == null || ObjectUtil.notEqual(invoice.getId(), excludeInvoiceId))
                .map(invoice -> zeroIfNull(invoice.getPrice()))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    /**
     * æ ¹æ®å‘票号码是否填写,设置发票录入状态
     */
    private void applyEntryStatus(CrmInvoiceDO invoice, String invoiceNo, CrmInvoiceDO oldInvoice) {
        if (StrUtil.isBlank(invoiceNo)) {
            invoice.setEntryStatus(0);
            return;
        }
        invoice.setEntryStatus(10);
        boolean changed = oldInvoice == null || !StrUtil.equals(invoiceNo, oldInvoice.getInvoiceNo())
                || !Integer.valueOf(10).equals(oldInvoice.getEntryStatus());
        if (changed) {
            invoice.setEntryUserId(getLoginUserId()).setEntryTime(LocalDateTime.now());
        }
    }
    /**
     * åŒæ­¥å¼€ç¥¨å•的收款节点到回款计划(crm_receivable_plan)
     *
     * ä¼  null è¡¨ç¤ºæœ¬æ¬¡ä¸ä¿®æ”¹èŠ‚ç‚¹ï¼›ä¼ ç©ºåˆ—è¡¨è¡¨ç¤ºæ¸…ç©ºèŠ‚ç‚¹ã€‚
     */
    private void syncPaymentNodes(CrmInvoiceDO invoice, List<CrmInvoiceSaveReqVO.PaymentNode> nodes) {
        if (nodes == null) {
            return;
        }
        validatePaymentNodes(nodes);
        // å·²äº§ç”Ÿå›žæ¬¾çš„节点不允许被覆盖
        for (CrmReceivablePlanDO oldPlan : receivablePlanMapper.selectListByInvoiceId(invoice.getId())) {
            if (oldPlan.getReceivableId() != null || zeroIfNull(oldPlan.getReceiptedPrice()).compareTo(BigDecimal.ZERO) > 0) {
                throw exception(INVOICE_PAYMENT_NODE_ALREADY_RECEIPTED,
                        CrmPaymentStageEnum.getNameByStage(oldPlan.getStage()),
                        formatPrice(zeroIfNull(oldPlan.getReceiptedPrice())));
            }
        }
        deletePaymentNodesByInvoiceId(invoice.getId());
        Long ownerUserId = invoice.getOwnerUserId() != null ? invoice.getOwnerUserId() : getLoginUserId();
        for (CrmInvoiceSaveReqVO.PaymentNode node : nodes) {
            CrmReceivablePlanDO maxPeriodPlan = receivablePlanMapper.selectMaxPeriodByContractId(invoice.getContractId());
            int period = maxPeriodPlan == null ? 1 : maxPeriodPlan.getPeriod() + 1;
            int remindDays = 3;
            CrmReceivablePlanDO plan = CrmReceivablePlanDO.builder()
                    .period(period).customerId(invoice.getCustomerId()).contractId(invoice.getContractId())
                    .ownerUserId(ownerUserId).returnTime(node.getReturnTime())
                    .remindDays(remindDays).remindTime(node.getReturnTime().minusDays(remindDays))
                    .price(node.getAmount()).stage(node.getStage()).invoiceId(invoice.getId())
                    .saleOrderId(invoice.getSaleOrderId())
                    .receiptedPrice(BigDecimal.ZERO).status(0).remark(node.getRemark())
                    .build();
            receivablePlanMapper.insert(plan);
            permissionService.createPermission(new CrmPermissionCreateReqBO()
                    .setBizType(CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType()).setBizId(plan.getId())
                    .setUserId(ownerUserId).setLevel(CrmPermissionLevelEnum.OWNER.getLevel()));
        }
    }
    private void validatePaymentNodes(List<CrmInvoiceSaveReqVO.PaymentNode> nodes) {
        if (CollUtil.isEmpty(nodes)) {
            return;
        }
        Set<Integer> stages = new HashSet<>();
        for (int i = 0; i < nodes.size(); i++) {
            CrmInvoiceSaveReqVO.PaymentNode node = nodes.get(i);
            int row = i + 1;
            CrmPaymentStageEnum stageEnum = node == null ? null : CrmPaymentStageEnum.valueOf(node.getStage());
            if (stageEnum == null) {
                throw exception(INVOICE_PAYMENT_NODE_STAGE_INVALID, row, node == null ? "未填写" : node.getStage());
            }
            if (!stages.add(stageEnum.getStage())) {
                throw exception(INVOICE_PAYMENT_NODE_STAGE_DUPLICATE, stageEnum.getName());
            }
            if (node.getAmount() == null || node.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
                throw exception(INVOICE_PAYMENT_NODE_INVALID, row, stageEnum.getName(),
                        node.getAmount() == null ? "未填写" : formatPrice(node.getAmount()));
            }
            if (node.getReturnTime() == null) {
                throw exception(INVOICE_PAYMENT_NODE_TIME_REQUIRED, stageEnum.getName());
            }
        }
    }
    private void deletePaymentNodesByInvoiceId(Long invoiceId) {
        for (CrmReceivablePlanDO plan : receivablePlanMapper.selectListByInvoiceId(invoiceId)) {
            receivablePlanMapper.deleteById(plan.getId());
            permissionService.deletePermission(CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType(), plan.getId());
        }
    }
    private String erpAuditStatusName(Integer status) {
        if (Objects.equals(status, ERP_SALE_ORDER_STATUS_APPROVE)) {
            return "已审核";
        }
        if (status == null) {
            return "未知状态";
        }
        return switch (status) {
            case 0 -> "未提交";
            case 10 -> "未审核";
            case 15 -> "审批中";
            case 30 -> "已作废";
            case 35 -> "已驳回";
            default -> "状态值 " + status;
        };
    }
    private String productSalesStatusName(Integer status) {
        if (status == null) {
            return "未知状态";
        }
        return switch (status) {
            case 0 -> "草稿";
            case 1 -> "待检测";
            case 2 -> "待拣货";
            case 3 -> "待执行出库";
            case 4 -> "已完成";
            case 5 -> "已取消";
            case 10 -> "待填写运单";
            default -> "状态值 " + status;
        };
    }
    private static BigDecimal zeroIfNull(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
    private static String formatPrice(BigDecimal value) {
        return (value == null ? BigDecimal.ZERO : value).stripTrailingZeros().toPlainString();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}",
@@ -410,15 +662,25 @@
                    StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(oldInvoice.getAuditStatus()), "未知状态"));
        }
        // 1.3 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        // 1.3 æ ¡éªŒé”€å”®è®¢å•、销售出库单,并回填出库单号
        validateSaleOrderAndOut(updateReqVO);
        // 1.4 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        validateInvoicePriceExceedsLimit(updateReqVO);
        // 2. æ›´æ–°å¼€ç¥¨
        CrmInvoiceDO updateObj = BeanUtils.toBean(updateReqVO, CrmInvoiceDO.class);
        applyEntryStatus(updateObj, updateReqVO.getInvoiceNo(), oldInvoice);
        invoiceMapper.updateById(updateObj);
        // å‘票号码被清空时,一并清空录入人、录入时间(updateById ä¼šå¿½ç•¥ null å­—段,需显式清空)
        if (StrUtil.isBlank(updateReqVO.getInvoiceNo())) {
            invoiceMapper.clearEntryInfo(updateReqVO.getId());
        }
        // 3. å¢žé‡æ›´æ–°å‘票附件
        storageAttachmentApi.updateAttachments("file", INVOICE_RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds());
        // 3.1 åŒæ­¥æ”¶æ¬¾èŠ‚ç‚¹ï¼ˆä»…å‰ç«¯ä¼ äº† paymentNodes æ‰è¦†ç›–,避免不传时误删)
        syncPaymentNodes(oldInvoice, updateReqVO.getPaymentNodes());
        // 4. è®°å½•操作日志上下文
        updateReqVO.setOwnerUserId(oldInvoice.getOwnerUserId()); // é¿å…æ“ä½œæ—¥å¿—出现“删除负责人”的情况
@@ -461,6 +723,8 @@
        invoiceMapper.deleteById(id);
        // 2.3 åˆ é™¤æ•°æ®æƒé™
        permissionService.deletePermission(CrmBizTypeEnum.CRM_INVOICE.getType(), id);
        // 2.4 åˆ é™¤å¼€ç¥¨ç”Ÿæˆçš„æ”¶æ¬¾èŠ‚ç‚¹ï¼ˆå®¡æ‰¹é€šè¿‡çš„å¼€ç¥¨ä¸å…è®¸åˆ é™¤ï¼Œæ•…æ­¤å¤„èŠ‚ç‚¹å¿…ç„¶æœªäº§ç”Ÿå›žæ¬¾ï¼‰
        deletePaymentNodesByInvoiceId(id);
        // 3. è®°å½•操作日志上下文
        LogRecordContext.putVariable("invoice", invoice);
@@ -555,4 +819,80 @@
        return invoiceMapper.selectCountByContractId(contractId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_ENTRY_SUB_TYPE, bizNo = "{{#reqVO.id}}",
            success = CRM_INVOICE_ENTRY_SUCCESS)
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_INVOICE, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.WRITE)
    public void entryInvoice(CrmInvoiceEntryReqVO reqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        CrmInvoiceDO invoice = validateInvoiceExists(reqVO.getId());
        // 1.2 åªæœ‰å®¡æ‰¹é€šè¿‡çš„开票申请才能录入发票
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(INVOICE_ENTRY_FAIL_NOT_APPROVE, invoice.getNo(),
                    StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(invoice.getAuditStatus()), "未知状态"));
        }
        // 1.3 å‘票号码不可与其他开票单重复
        String invoiceNo = reqVO.getInvoiceNo().trim();
        CrmInvoiceDO invoiceNoExists = invoiceMapper.selectByInvoiceNo(invoiceNo);
        if (invoiceNoExists != null && ObjectUtil.notEqual(invoiceNoExists.getId(), invoice.getId())) {
            throw exception(INVOICE_ENTRY_FAIL_INVOICE_NO_EXISTS, invoiceNo, invoiceNoExists.getNo());
        }
        // 2. å½•入发票信息,状态置为已录入
        invoiceMapper.updateById(new CrmInvoiceDO().setId(invoice.getId())
                .setInvoiceNo(invoiceNo).setInvoiceTitle(reqVO.getInvoiceTitle())
                .setInvoiceTime(reqVO.getInvoiceTime()).setRemark(reqVO.getRemark())
                .setEntryStatus(10).setEntryUserId(getLoginUserId()).setEntryTime(LocalDateTime.now()));
        // 3. æ›´æ–°å‘票附件
        if (reqVO.getBlobIds() != null) {
            storageAttachmentApi.updateAttachments("file", INVOICE_RECORD_TYPE, invoice.getId(), reqVO.getBlobIds());
        }
        // 4. åŒæ­¥æ”¶æ¬¾èŠ‚ç‚¹
        syncPaymentNodes(invoice, reqVO.getPaymentNodes());
        // 5. è®°å½•操作日志上下文
        LogRecordContext.putVariable("invoiceNo", invoice.getNo());
        LogRecordContext.putVariable("entryInvoiceNo", invoiceNo);
    }
    @Override
    public List<CrmInvoiceSaleOutRespVO> getSaleOutListForInvoice(Long saleOrderId, Long excludeInvoiceId) {
        if (saleOrderId == null) {
            return ListUtil.empty();
        }
        List<MesWmProductSalesRespDTO> saleOutList = productSalesApi.getProductSalesListBySaleOrderId(saleOrderId);
        if (CollUtil.isEmpty(saleOutList)) {
            return ListUtil.empty();
        }
        List<CrmInvoiceSaleOutRespVO> result = new ArrayList<>();
        for (MesWmProductSalesRespDTO saleOut : saleOutList) {
            // å·²å–消的出库单不可开票
            if (ObjectUtil.equal(saleOut.getStatus(), MES_PRODUCT_SALES_STATUS_CANCELED)) {
                continue;
            }
            BigDecimal totalPrice = zeroIfNull(saleOut.getTotalPrice());
            BigDecimal invoicedPrice = sumInvoicePrice(invoiceMapper.selectListBySaleOutIdAndStatus(
                    saleOut.getId(), INVOICE_OCCUPY_STATUSES), excludeInvoiceId);
            CrmInvoiceSaleOutRespVO vo = new CrmInvoiceSaleOutRespVO();
            vo.setId(saleOut.getId());
            vo.setCode(saleOut.getCode());
            vo.setSaleOrderId(saleOut.getSaleOrderId());
            vo.setSalesOrderCode(saleOut.getSalesOrderCode());
            vo.setSalesDate(saleOut.getSalesDate());
            vo.setStatus(saleOut.getStatus());
            vo.setStatusName(productSalesStatusName(saleOut.getStatus()));
            vo.setTotalPrice(totalPrice);
            vo.setInvoicedPrice(invoicedPrice);
            vo.setRemainInvoicePrice(totalPrice.subtract(invoicedPrice));
            result.add(vo);
        }
        return result;
    }
    @Override
    public List<CrmReceivablePlanDO> getPaymentNodeListByInvoiceId(Long invoiceId) {
        return receivablePlanMapper.selectListByInvoiceId(invoiceId);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanService.java
@@ -41,6 +41,22 @@
    void updateReceivablePlanReceivableId(Long id, Long receivableId);
    /**
     * å›žå†™å›žæ¬¾è®¡åˆ’的实际回款金额、实际回款时间与状态
     *
     * @param id            å›žæ¬¾è®¡åˆ’编号
     * @param receiptPrice  å›žæ¬¾é‡‘额
     * @param actualTime    å®žé™…回款时间
     */
    void updateReceivablePlanReceipted(Long id, java.math.BigDecimal receiptPrice, java.time.LocalDateTime actualTime);
    /**
     * åå®¡æ ¸å›žæ¬¾æ—¶ï¼Œæ¸…空回款计划的实际回款信息
     *
     * @param id å›žæ¬¾è®¡åˆ’编号
     */
    void resetReceivablePlanReceipted(Long id);
    /**
     * åˆ é™¤å›žæ¬¾è®¡åˆ’
     *
     * @param id ç¼–号
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanServiceImpl.java
@@ -24,6 +24,7 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
@@ -130,6 +131,31 @@
    }
    @Override
    public void updateReceivablePlanReceipted(Long id, java.math.BigDecimal receiptPrice,
                                              java.time.LocalDateTime actualTime) {
        CrmReceivablePlanDO plan = validateReceivablePlanExists(id);
        BigDecimal receiptedPrice = plan.getReceiptedPrice() == null ? java.math.BigDecimal.ZERO : plan.getReceiptedPrice();
        receiptedPrice = receiptedPrice.add(receiptPrice == null ? java.math.BigDecimal.ZERO : receiptPrice);
        // çŠ¶æ€ï¼š0未回款 10部分回款 20已回清
        int status = receiptedPrice.compareTo(plan.getPrice()) >= 0 ? 20 : (receiptedPrice.signum() > 0 ? 10 : 0);
        receivablePlanMapper.updateById(new CrmReceivablePlanDO().setId(id)
                .setReceiptedPrice(receiptedPrice).setStatus(status)
                .setActualTime(actualTime != null ? actualTime : java.time.LocalDateTime.now()));
    }
    @Override
    public void resetReceivablePlanReceipted(Long id) {
        validateReceivablePlanExists(id);
        // updateById ä¼šå¿½ç•¥ null,需显式清空实际回款时间
        receivablePlanMapper.update(null,
                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<CrmReceivablePlanDO>()
                        .eq(CrmReceivablePlanDO::getId, id)
                        .set(CrmReceivablePlanDO::getReceiptedPrice, java.math.BigDecimal.ZERO)
                        .set(CrmReceivablePlanDO::getStatus, 0)
                        .set(CrmReceivablePlanDO::getActualTime, null));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_RECEIVABLE_PLAN_TYPE, subType = CRM_RECEIVABLE_PLAN_DELETE_SUB_TYPE, bizNo = "{{#id}}",
            success = CRM_RECEIVABLE_PLAN_DELETE_SUCCESS)
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java
@@ -5,6 +5,7 @@
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableItemDO;
import jakarta.validation.Valid;
import java.math.BigDecimal;
@@ -68,6 +69,22 @@
    List<CrmReceivableDO> getReceivableList(Collection<Long> ids);
    /**
     * èŽ·å¾—å›žæ¬¾çš„æ ¸é”€æ˜Žç»†
     *
     * @param receivableId å›žæ¬¾ç¼–号
     * @return æ ¸é”€æ˜Žç»†åˆ—表
     */
    List<CrmReceivableItemDO> getReceivableItemList(Long receivableId);
    /**
     * èŽ·å¾—å®¢æˆ·ä¸‹å…¨éƒ¨å›žæ¬¾çš„æ ¸é”€æ˜Žç»†
     *
     * @param customerId å®¢æˆ·ç¼–号
     * @return æ ¸é”€æ˜Žç»†åˆ—表(按回款倒序)
     */
    List<CrmReceivableItemDO> getReceivableItemListByCustomerId(Long customerId);
    /**
     * èŽ·å¾—å›žæ¬¾ Map
     *
     * @param ids ç¼–号
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
@@ -13,12 +13,19 @@
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableItemDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableItemMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableMapper;
import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableBizTypeEnum;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
import cn.iocoder.yudao.module.mes.api.productsales.MesWmProductSalesApi;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO;
import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.invoice.CrmInvoiceService;
@@ -54,6 +61,13 @@
    @Resource
    private CrmReceivableMapper receivableMapper;
    @Resource
    private CrmReceivableItemMapper receivableItemMapper;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MesWmProductSalesApi productSalesApi;
    @Resource
    private CrmNoRedisDAO noRedisDAO;
@@ -96,7 +110,8 @@
        CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class)
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus());
        receivableMapper.insert(receivable);
        // 2.2
        // 2.2 ä¿å­˜æ ¸é”€æ˜Žç»†ï¼ˆæœªä¼ æ—¶æŒ‰å…³è”开票自动生成一条)
        syncReceivableItems(receivable, createReqVO.getItems());
        // 3. åˆ›å»ºæ•°æ®æƒé™
        permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_RECEIVABLE.getType())
@@ -112,6 +127,121 @@
        LogRecordContext.putVariable("receivable", receivable);
        LogRecordContext.putVariable("period", getReceivablePeriod(receivable.getPlanId()));
        return receivable.getId();
    }
    /**
     * ä¿å­˜æ ¸é”€æ˜Žç»†
     *
     * ä¼  null æ—¶ï¼šè‹¥å…³è”开票,自动生成一条「开票」核销明细;否则不生成。
     */
    private void syncReceivableItems(CrmReceivableDO receivable,
                                     List<CrmReceivableSaveReqVO.ReceivableItem> items) {
        if (items == null) {
            if (receivable.getInvoiceId() == null) {
                return;
            }
            CrmReceivableSaveReqVO.ReceivableItem autoItem = new CrmReceivableSaveReqVO.ReceivableItem();
            autoItem.setBizType(CrmReceivableBizTypeEnum.SALE_INVOICE.getType());
            autoItem.setBizId(receivable.getInvoiceId());
            autoItem.setReceiptPrice(receivable.getPrice());
            items = List.of(autoItem);
        }
        // 1. æ ¡éªŒå¹¶è§£æžæ¯è¡Œå…³è”单据
        List<ResolvedReceivableItem> resolvedItems = new ArrayList<>(items.size());
        BigDecimal itemPriceSum = BigDecimal.ZERO;
        for (int i = 0; i < items.size(); i++) {
            CrmReceivableSaveReqVO.ReceivableItem item = items.get(i);
            int row = i + 1;
            CrmReceivableBizTypeEnum bizType = item == null ? null
                    : CrmReceivableBizTypeEnum.valueOf(item.getBizType());
            if (bizType == null) {
                throw exception(RECEIVABLE_ITEM_BIZ_TYPE_INVALID, row,
                        item == null ? "未填写" : item.getBizType());
            }
            if (item.getReceiptPrice() == null || item.getReceiptPrice().compareTo(BigDecimal.ZERO) <= 0) {
                throw exception(RECEIVABLE_ITEM_PRICE_INVALID, row, bizType.getName(),
                        item.getBizId(), item.getReceiptPrice() == null ? "未填写"
                                : item.getReceiptPrice().stripTrailingZeros().toPlainString());
            }
            ResolvedReceivableItem resolved = resolveReceivableItem(row, bizType, item.getBizId());
            // 1.1 å·²æ ¸é”€é‡‘额(不含本回款单,避免编辑时把自己算进去)
            BigDecimal receiptedPrice = receivableItemMapper.selectListByBiz(bizType.getType(), item.getBizId()).stream()
                    .filter(old -> ObjectUtil.notEqual(old.getReceivableId(), receivable.getId()))
                    .map(old -> old.getReceiptPrice() == null ? BigDecimal.ZERO : old.getReceiptPrice())
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal remainPrice = resolved.totalPrice.subtract(receiptedPrice);
            if (item.getReceiptPrice().compareTo(remainPrice) > 0) {
                throw exception(RECEIVABLE_ITEM_PRICE_EXCEEDS, row, bizType.getName(), resolved.bizNo,
                        item.getReceiptPrice().stripTrailingZeros().toPlainString(),
                        remainPrice.stripTrailingZeros().toPlainString(),
                        resolved.totalPrice.stripTrailingZeros().toPlainString(),
                        receiptedPrice.stripTrailingZeros().toPlainString());
            }
            resolved.receiptedPrice = receiptedPrice;
            resolved.receiptPrice = item.getReceiptPrice();
            resolved.remark = item.getRemark();
            resolvedItems.add(resolved);
            itemPriceSum = itemPriceSum.add(item.getReceiptPrice());
        }
        // 2. æ˜Žç»†åˆè®¡å¿…须等于回款金额
        if (itemPriceSum.compareTo(receivable.getPrice()) != 0) {
            throw exception(RECEIVABLE_ITEM_SUM_MISMATCH,
                    itemPriceSum.stripTrailingZeros().toPlainString(),
                    receivable.getPrice().stripTrailingZeros().toPlainString());
        }
        // 3. é‡å»ºæ˜Žç»†
        receivableItemMapper.deleteByReceivableId(receivable.getId());
        for (ResolvedReceivableItem resolved : resolvedItems) {
            receivableItemMapper.insert(CrmReceivableItemDO.builder()
                    .receivableId(receivable.getId()).bizType(resolved.bizType.getType())
                    .bizId(resolved.bizId).bizNo(resolved.bizNo).totalPrice(resolved.totalPrice)
                    .receiptedPrice(resolved.receiptedPrice).receiptPrice(resolved.receiptPrice)
                    .remark(resolved.remark).build());
        }
    }
    private ResolvedReceivableItem resolveReceivableItem(int row, CrmReceivableBizTypeEnum bizType, Long bizId) {
        ResolvedReceivableItem resolved = new ResolvedReceivableItem();
        resolved.bizType = bizType;
        resolved.bizId = bizId;
        switch (bizType) {
            case SALE_ORDER -> {
                ErpSaleOrderRespDTO saleOrder = bizId == null ? null : saleOrderApi.getSaleOrder(bizId).getCheckedData();
                if (saleOrder == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = saleOrder.getNo();
                resolved.totalPrice = saleOrder.getTotalPrice() == null ? BigDecimal.ZERO : saleOrder.getTotalPrice();
            }
            case SALE_OUT -> {
                MesWmProductSalesRespDTO saleOut = bizId == null ? null : productSalesApi.getProductSales(bizId);
                if (saleOut == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = saleOut.getCode();
                resolved.totalPrice = saleOut.getTotalPrice() == null ? BigDecimal.ZERO : saleOut.getTotalPrice();
            }
            case SALE_INVOICE -> {
                CrmInvoiceDO invoice = bizId == null ? null : invoiceService.getInvoice(bizId);
                if (invoice == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = invoice.getNo();
                resolved.totalPrice = invoice.getPrice() == null ? BigDecimal.ZERO : invoice.getPrice();
            }
            default -> throw exception(RECEIVABLE_ITEM_BIZ_TYPE_INVALID, row, bizType.getType());
        }
        return resolved;
    }
    private static class ResolvedReceivableItem {
        private CrmReceivableBizTypeEnum bizType;
        private Long bizId;
        private String bizNo;
        private BigDecimal totalPrice;
        private BigDecimal receiptedPrice;
        private BigDecimal receiptPrice;
        private String remark;
    }
    private void validateReceivablePriceExceedsLimit(CrmReceivableSaveReqVO reqVO) {
@@ -243,6 +373,11 @@
        // 2. æ›´æ–°å›žæ¬¾
        CrmReceivableDO updateObj = BeanUtils.toBean(updateReqVO, CrmReceivableDO.class);
        receivableMapper.updateById(updateObj);
        // 2.1 æ›´æ–°æ ¸é”€æ˜Žç»†ï¼šä¼ äº† items ä»¥ items ä¸ºå‡†ï¼›æœªä¼ ä¸”关联开票时按开票重建;否则保持不变
        if (updateReqVO.getItems() != null
                || (updateReqVO.getInvoiceId() != null && ObjectUtil.notEqual(updateReqVO.getInvoiceId(), oldReceivable.getInvoiceId()))) {
            syncReceivableItems(updateObj, updateReqVO.getItems());
        }
        // 3. è®°å½•操作日志上下文
        updateReqVO.setOwnerUserId(oldReceivable.getOwnerUserId()); // é¿å…æ“ä½œæ—¥å¿—出现“删除负责人”的情况
@@ -276,6 +411,16 @@
        if (updateCount == 0) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
        }
        // 3. å›žæ¬¾è®¡åˆ’回写实际回款金额与状态
        if (receivable.getPlanId() != null) {
            if (approve) {
                receivablePlanService.updateReceivablePlanReceipted(receivable.getPlanId(),
                        receivable.getPrice(), receivable.getReturnTime());
            } else {
                receivablePlanService.resetReceivablePlanReceipted(receivable.getPlanId());
            }
        }
    }
    @Override
@@ -297,7 +442,9 @@
        // 2.1 åˆ é™¤å›žæ¬¾
        receivableMapper.deleteById(id);
        // 2.2 åˆ é™¤æ•°æ®æƒé™
        // 2.2 åˆ é™¤æ ¸é”€æ˜Žç»†
        receivableItemMapper.deleteByReceivableId(id);
        // 2.3 åˆ é™¤æ•°æ®æƒé™
        permissionService.deletePermission(CrmBizTypeEnum.CRM_RECEIVABLE.getType(), id);
        // 3. è®°å½•操作日志上下文
@@ -333,6 +480,21 @@
    }
    @Override
    public List<CrmReceivableItemDO> getReceivableItemList(Long receivableId) {
        return receivableItemMapper.selectListByReceivableId(receivableId);
    }
    @Override
    public List<CrmReceivableItemDO> getReceivableItemListByCustomerId(Long customerId) {
        List<CrmReceivableDO> receivables = receivableMapper.selectListByCustomerId(customerId);
        if (CollUtil.isEmpty(receivables)) {
            return ListUtil.empty();
        }
        return receivableItemMapper.selectListByReceivableIds(
                receivables.stream().map(CrmReceivableDO::getId).toList());
    }
    @Override
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ)
    public PageResult<CrmReceivableDO> getReceivablePageByCustomerId(CrmReceivablePageReqVO pageReqVO) {
        return receivableMapper.selectPageByCustomerId(pageReqVO);
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/util/CrmPermissionUtils.java
@@ -7,14 +7,19 @@
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.hrm.api.handover.HrmUserHandoverApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.github.yulichang.autoconfigure.MybatisPlusJoinProperties;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@@ -24,6 +29,7 @@
 *
 * @author HUIHUI
 */
@Slf4j
public class CrmPermissionUtils {
    /** è¶…级管理员查看客户时不受公海可见度开关影响。 */
@@ -58,6 +64,45 @@
    }
    /**
     * èŽ·å¾—"我负责的数据"对应的用户编号集合:本人 + å·²å°†æ•°æ®äº¤æŽ¥ç»™æˆ‘的离职用户
     *
     * ç¦»èŒäº¤æŽ¥å®ŒæˆåŽï¼Œäº¤æŽ¥äººéœ€è¦èƒ½çœ‹åˆ°ç¦»èŒäººåä¸‹çš„æ•°æ®ï¼Œå› æ­¤è¿™é‡Œé€šè¿‡ HrmUserHandoverApi æ‰©å±•。
     *
     * @param userId å½“前用户编号
     * @return ç”¨æˆ·ç¼–号集合,至少包含本人
     */
    public static Set<Long> getOwnerUserIds(Long userId) {
        Set<Long> ownerUserIds = new LinkedHashSet<>();
        if (userId == null) {
            return ownerUserIds;
        }
        ownerUserIds.add(userId);
        try {
            HrmUserHandoverApi handoverApi = SpringUtil.getBean(HrmUserHandoverApi.class);
            Set<Long> handoverUserIds = handoverApi.expandUserIds(new HashSet<>(ownerUserIds));
            if (CollUtil.isNotEmpty(handoverUserIds)) {
                ownerUserIds.addAll(handoverUserIds);
            }
        } catch (Exception ex) {
            // HRM æ¨¡å—未部署时降级为只看本人数据,不影响 CRM æ­£å¸¸ä½¿ç”¨
            log.warn("[getOwnerUserIds][扩展交接用户失败,userIds({}) ä»…按本人数据查询]", userId, ex);
        }
        return ownerUserIds;
    }
    /**
     * è¿½åŠ "我负责的数据"的过滤条件,支持离职交接后的数据可见性扩展
     */
    private static <T extends MPJLambdaWrapper<?>> void appendOwnerUserCondition(T query, String ownerUserIdField, Long userId) {
        Set<Long> ownerUserIds = getOwnerUserIds(userId);
        if (ownerUserIds.size() <= 1) {
            query.eq(ownerUserIdField, userId);
        } else {
            query.in(ownerUserIdField, ownerUserIds);
        }
    }
    /**
     * æž„造 CRM æ•°æ®ç±»åž‹æ•°æ®ã€åˆ†é¡µã€‘查询条件
     *
     * @param query     è¿žè¡¨æŸ¥è¯¢å¯¹è±¡
@@ -70,9 +115,9 @@
                                                                                    Long userId, Integer sceneType) {
        MybatisPlusJoinProperties mybatisPlusJoinProperties = SpringUtil.getBean(MybatisPlusJoinProperties.class);
        final String ownerUserIdField = mybatisPlusJoinProperties.getTableAlias() + ".owner_user_id";
        // åœºæ™¯ä¸€ï¼šæˆ‘负责的数据
        // åœºæ™¯ä¸€ï¼šæˆ‘负责的数据(含离职交接给我的数据)
        if (CrmSceneTypeEnum.isOwner(sceneType)) {
            query.eq(ownerUserIdField, userId);
            appendOwnerUserCondition(query, ownerUserIdField, userId);
        }
        // åœºæ™¯äºŒï¼šæˆ‘参与的数据(我有读或写权限,并且不是负责人)
        if (CrmSceneTypeEnum.isInvolved(sceneType)) {
@@ -105,8 +150,8 @@
            if (isCrmAdmin() || hasCustomerViewAllPermission()) {
                return; // è·³è¿‡è¿‡æ»¤ï¼ŒæŸ¥çœ‹å…¨éƒ¨
            }
            // æ— æƒé™åˆ™é™çº§ä¸º"我负责的"
            query.eq(ownerUserIdField, userId);
            // æ— æƒé™åˆ™é™çº§ä¸º"我负责的"(含离职交接给我的数据)
            appendOwnerUserCondition(query, ownerUserIdField, userId);
        }
    }
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApi.java
@@ -42,6 +42,21 @@
    CommonResult<ErpSaleOrderRespDTO> getSaleOrderByContractId(Long contractId);
    /**
     * æ ¹æ®å®¢æˆ·ç¼–号获取销售订单列表
     *
     * @param customerId å®¢æˆ·ç¼–号
     * @return é”€å”®è®¢å•列表
     */
    CommonResult<List<ErpSaleOrderRespDTO>> getSaleOrderListByCustomerId(Long customerId);
    /**
     * èŽ·å–å…¨éƒ¨é”€å”®è®¢å•åˆ—è¡¨ï¼ˆè´¢åŠ¡å¯¹è´¦ã€æŠ¥è¡¨ä½¿ç”¨ï¼‰
     *
     * @return é”€å”®è®¢å•列表
     */
    CommonResult<List<ErpSaleOrderRespDTO>> getSaleOrderList();
    /**
     * èŽ·å–é”€å”®è®¢å•æ˜Žç»†åˆ—è¡¨
     *
     * @param orderId è®¢å•编号
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApiImpl.java
@@ -67,6 +67,20 @@
    }
    @Override
    public CommonResult<List<ErpSaleOrderRespDTO>> getSaleOrderListByCustomerId(Long customerId) {
        if (customerId == null) {
            return success(Collections.emptyList());
        }
        List<ErpSaleOrderDO> orders = saleOrderMapper.selectList(ErpSaleOrderDO::getCustomerId, customerId);
        return success(BeanUtils.toBean(orders, ErpSaleOrderRespDTO.class));
    }
    @Override
    public CommonResult<List<ErpSaleOrderRespDTO>> getSaleOrderList() {
        return success(BeanUtils.toBean(saleOrderMapper.selectList(), ErpSaleOrderRespDTO.class));
    }
    @Override
    public CommonResult<List<ErpSaleOrderItemRespDTO>> getSaleOrderItemList(Long orderId) {
        if (orderId == null) {
            return success(Collections.emptyList());
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java
@@ -9,6 +9,7 @@
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceItemReceiptRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoicePageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceSaveReqVO;
@@ -166,6 +167,16 @@
        });
    }
    @GetMapping("/item-receipt-list")
    @Operation(summary = "获得来票可选入库单列表", description = "按采购订单过滤 MES å…¥åº“单,仅返回未取消的入库单,并带出可来票金额")
    @Parameter(name = "purchaseOrderId", description = "采购订单编号", required = true)
    @Parameter(name = "id", description = "来票编号(编辑时传入,用于排除自身已占用金额)")
    public CommonResult<List<ErpPurchaseInvoiceItemReceiptRespVO>> getItemReceiptList(
            @RequestParam("purchaseOrderId") Long purchaseOrderId,
            @RequestParam(value = "id", required = false) Long id) {
        return success(invoiceService.getItemReceiptListForInvoice(purchaseOrderId, id));
    }
    @GetMapping("/simple-list")
    @Operation(summary = "获得来票精简列表", description = "只包含已上传发票附件的来票,主要用于付款创建时选择关联来票")
    @Parameter(name = "supplierId", description = "供应商编号", required = true)
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseRequestController.java
@@ -109,13 +109,29 @@
                convertSet(purchaseRequestItemList, ErpPurchaseRequestItemDO::getProductId));
        Map<Long, MdmUnitMeasureRespDTO> unitMeasureMap = mdmUnitMeasureApi.getUnitMeasureMap(
                convertSet(purchaseRequestItemList, ErpPurchaseRequestItemDO::getProductUnitId));
        return success(BeanUtils.toBean(purchaseRequest, ErpPurchaseRequestRespVO.class, purchaseRequestVO ->
                purchaseRequestVO.setItems(BeanUtils.toBean(purchaseRequestItemList, ErpPurchaseRequestRespVO.Item.class, item -> {
                    MapUtils.findAndThen(itemMap, item.getProductId(), mdmItem -> item.setProductName(mdmItem.getName())
                            .setProductBarCode(mdmItem.getBarCode()));
                    MapUtils.findAndThen(unitMeasureMap, item.getProductUnitId(), unit ->
                            item.setProductUnitName(unit.getName()));
                })).setAttachmentList(storageAttachmentApi.listAttachments("erp_purchase_request", id))));
        // ä¾›åº”商 / ç”³è¯·äºº / ç”³è¯·éƒ¨é—¨åç§°ï¼šå®¡æ‰¹è¯¦æƒ…页直接展示该接口的数据,缺失会显示为“-”
        List<ErpPurchaseRequestDO> singleList = java.util.Collections.singletonList(purchaseRequest);
        Map<Long, SrmSupplierRespDTO> supplierMap = srmSupplierApi.getSupplierList(
                        convertSet(singleList, ErpPurchaseRequestDO::getSupplierId)).getCheckedData().stream()
                .collect(java.util.stream.Collectors.toMap(SrmSupplierRespDTO::getId, v -> v, (a, b) -> a));
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(singleList, ErpPurchaseRequestDO::getRequestUserId));
        Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(
                convertSet(singleList, ErpPurchaseRequestDO::getRequestDeptId));
        return success(BeanUtils.toBean(purchaseRequest, ErpPurchaseRequestRespVO.class, purchaseRequestVO -> {
            purchaseRequestVO.setItems(BeanUtils.toBean(purchaseRequestItemList, ErpPurchaseRequestRespVO.Item.class, item -> {
                MapUtils.findAndThen(itemMap, item.getProductId(), mdmItem -> item.setProductName(mdmItem.getName())
                        .setProductBarCode(mdmItem.getBarCode()));
                MapUtils.findAndThen(unitMeasureMap, item.getProductUnitId(), unit ->
                        item.setProductUnitName(unit.getName()));
            })).setAttachmentList(storageAttachmentApi.listAttachments("erp_purchase_request", id));
            MapUtils.findAndThen(supplierMap, purchaseRequestVO.getSupplierId(),
                    supplier -> purchaseRequestVO.setSupplierName(supplier.getName()));
            MapUtils.findAndThen(userMap, purchaseRequestVO.getRequestUserId(),
                    user -> purchaseRequestVO.setRequestUserName(user.getNickname()));
            MapUtils.findAndThen(deptMap, purchaseRequestVO.getRequestDeptId(),
                    dept -> purchaseRequestVO.setRequestDeptName(dept.getName()));
        }));
    }
    @GetMapping("/page")
@@ -189,7 +205,7 @@
    @Operation(summary = "生成采购订单")
    @PreAuthorize("@ss.hasPermission('erp:purchase-request:generate-order')")
    public CommonResult<Long> generatePurchaseOrder(@RequestParam("id") Long id,
                                                    @RequestParam("supplierId") Long supplierId) {
                                                    @RequestParam(value = "supplierId", required = false) Long supplierId) {
        return success(purchaseRequestService.generatePurchaseOrder(id, supplierId));
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpSupplierStatementController.java
@@ -3,6 +3,8 @@
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementSummaryReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementSummaryRespVO;
import cn.iocoder.yudao.module.erp.service.purchase.ErpSupplierStatementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -13,6 +15,8 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -33,4 +37,12 @@
        return success(supplierStatementService.getSupplierStatement(reqVO));
    }
    @GetMapping("/summary-list")
    @Operation(summary = "获取各供应商往来汇总列表(左侧供应商总览)")
    @PreAuthorize("@ss.hasPermission('erp:supplier-statement:query')")
    public CommonResult<List<ErpSupplierStatementSummaryRespVO>> getSupplierSummaryList(
            @Valid ErpSupplierStatementSummaryReqVO reqVO) {
        return success(supplierStatementService.getSupplierSummaryList(reqVO));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceItemReceiptRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - æ¥ç¥¨å¯é€‰å…¥åº“单 Response VO")
@Data
@Accessors(chain = true)
public class ErpPurchaseInvoiceItemReceiptRespVO {
    @Schema(description = "入库单编号", example = "1024")
    private Long id;
    @Schema(description = "入库单号", example = "IR202611050001")
    private String code;
    @Schema(description = "入库单名称")
    private String name;
    @Schema(description = "入库日期")
    private LocalDateTime receiptDate;
    @Schema(description = "入库单状态", example = "4")
    private Integer status;
    @Schema(description = "入库金额", example = "1000.00")
    private BigDecimal totalPrice;
    @Schema(description = "已来票金额", example = "200.00")
    private BigDecimal invoicedPrice;
    @Schema(description = "可来票金额", example = "800.00")
    private BigDecimal remainInvoicePrice;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java
@@ -36,6 +36,13 @@
    @Schema(description = "采购订单信息")
    private ErpPurchaseOrderRespVO order;
    @Schema(description = "关联入库单编号")
    private Long itemReceiptId;
    @Schema(description = "关联入库单号")
    @ExcelProperty("入库单号")
    private String itemReceiptCode;
    @Schema(description = "发票号码", example = "02564178")
    @ExcelProperty("发票号码")
    private String invoiceNo;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java
@@ -22,9 +22,12 @@
    @NotNull(message = "采购订单编号不能为空")
    private Long purchaseOrderId;
    @Schema(description = "关联入库单")
    @Schema(description = "关联到货通知单")
    private Long arrivalNoticeId;
    @Schema(description = "关联入库单编号")
    private Long itemReceiptId;
    @Schema(description = "发票号码", example = "02564178")
    private String invoiceNo;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/statement/ErpSupplierStatementSummaryReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - ä¾›åº”商往来汇总列表查询 Request VO")
@Data
public class ErpSupplierStatementSummaryReqVO {
    @Schema(description = "开始时间")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime beginTime;
    @Schema(description = "结束时间")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime endTime;
    @Schema(description = "是否包含未审核采购订单")
    private Boolean includeUnapprovedOrder;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/statement/ErpSupplierStatementSummaryRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - ä¾›åº”商往来汇总(左侧总览行) Response VO")
@Data
public class ErpSupplierStatementSummaryRespVO {
    @Schema(description = "供应商编号")
    private Long supplierId;
    @Schema(description = "供应商编码")
    private String supplierCode;
    @Schema(description = "供应商名称")
    private String supplierName;
    @Schema(description = "采购订单数量")
    private Integer orderCount;
    @Schema(description = "来票数量")
    private Integer invoiceCount;
    @Schema(description = "付款单数量")
    private Integer paymentCount;
    @Schema(description = "累计采购额")
    private BigDecimal totalPurchaseAmount;
    @Schema(description = "累计开票额")
    private BigDecimal totalInvoiceAmount;
    @Schema(description = "累计付款额")
    private BigDecimal totalPaymentAmount;
    @Schema(description = "未付余额(开票 - ä»˜æ¬¾ï¼‰")
    private BigDecimal unpaidAmount;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceDO.java
@@ -46,9 +46,19 @@
     */
    private Long purchaseOrderId;
    /** å…³è”入库单 */
    /** å…³è”到货通知单 */
    private Long arrivalNoticeId;
    /**
     * å…³è”入库单编号
     *
     * å…³è” MES é‡‡è´­å…¥åº“单 mes_wm_item_receipt
     */
    private Long itemReceiptId;
    /**
     * å…³è”入库单号(快照,列表直接展示)
     */
    private String itemReceiptCode;
    /**
     * å‘票号码
     */
    private String invoiceNo;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java
@@ -103,4 +103,16 @@
                .orderByAsc(ErpFinancePaymentDO::getId));
    }
    /**
     * æŒ‰ä»˜æ¬¾æ—¶é—´èŒƒå›´æŸ¥è¯¢å…¨éƒ¨ä¾›åº”商的付款单(供应商往来汇总)
     */
    default List<ErpFinancePaymentDO> selectListByPaymentTimeBetween(LocalDateTime beginTime,
                                                                     LocalDateTime endTime,
                                                                     Collection<Integer> statuses) {
        return selectList(new LambdaQueryWrapperX<ErpFinancePaymentDO>()
                .geIfPresent(ErpFinancePaymentDO::getPaymentTime, beginTime)
                .leIfPresent(ErpFinancePaymentDO::getPaymentTime, endTime)
                .inIfPresent(ErpFinancePaymentDO::getStatus, statuses));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java
@@ -46,6 +46,11 @@
                .in(ErpPurchaseInvoiceDO::getPurchaseOrderId, orderIds));
    }
    default List<ErpPurchaseInvoiceDO> selectListByItemReceiptId(Long itemReceiptId) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .eq(ErpPurchaseInvoiceDO::getItemReceiptId, itemReceiptId));
    }
    /**
     * æŒ‰ä¾›åº”商与开票时间范围查询来票(供应商往来)
     */
@@ -60,4 +65,14 @@
                .orderByAsc(ErpPurchaseInvoiceDO::getId));
    }
    /**
     * æŒ‰å¼€ç¥¨æ—¶é—´èŒƒå›´æŸ¥è¯¢å…¨éƒ¨ä¾›åº”商的来票(供应商往来汇总)
     */
    default List<ErpPurchaseInvoiceDO> selectListByInvoiceTimeBetween(LocalDateTime beginTime,
                                                                      LocalDateTime endTime) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .geIfPresent(ErpPurchaseInvoiceDO::getInvoiceTime, beginTime)
                .leIfPresent(ErpPurchaseInvoiceDO::getInvoiceTime, endTime));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseOrderMapper.java
@@ -121,4 +121,16 @@
                .orderByAsc(ErpPurchaseOrderDO::getId));
    }
    /**
     * æŒ‰ä¸‹å•时间范围查询全部供应商的采购订单(供应商往来汇总)
     */
    default List<ErpPurchaseOrderDO> selectListByOrderTimeBetween(LocalDateTime beginTime,
                                                                  LocalDateTime endTime,
                                                                  Collection<Integer> statuses) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseOrderDO>()
                .geIfPresent(ErpPurchaseOrderDO::getOrderTime, beginTime)
                .leIfPresent(ErpPurchaseOrderDO::getOrderTime, endTime)
                .inIfPresent(ErpPurchaseOrderDO::getStatus, statuses));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
@@ -30,7 +30,7 @@
    ErrorCode PURCHASE_ORDER_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_101_012, "采购审核分类下没有可用的流程模型,请先创建并发布流程");
    ErrorCode PURCHASE_ORDER_SUBMIT_FAIL_STATUS = new ErrorCode(1_030_101_013, "提交审批失败,只有未审核状态的采购订单才能提交");
    ErrorCode PURCHASE_ORDER_ALL_IN = new ErrorCode(1_030_101_014, "采购订单已全部入库,无需确认收货");
    ErrorCode PURCHASE_ORDER_PAYMENT_DATE_REQUIRED = new ErrorCode(1_030_101_015, "付款日期不能为空,请选择付款日期后再保存");
    ErrorCode PURCHASE_ORDER_PAYMENT_DATE_REQUIRED = new ErrorCode(1_030_101_015, "付款日期不能为空,请先编辑采购台账选择付款日期,再提交");
    ErrorCode PURCHASE_ORDER_PURCHASE_TYPE_INVALID = new ErrorCode(1_030_101_016, "采购类型({})不合法,只能选择:1生产、2办公用品、3其他");
    ErrorCode PURCHASE_ORDER_PAYMENT_METHOD_INVALID = new ErrorCode(1_030_101_017, "付款方式({})不合法,只能选择:1电汇、2汇票、3现金、4手机支付");
    ErrorCode PURCHASE_ORDER_ITEM_PRODUCT_CODE_MISSING = new ErrorCode(1_030_101_018, "物料[{}]未维护 MDM ç‰©æ–™ç¼–码,请先在 MDM ç‰©æ–™æ¡£æ¡ˆè¡¥å……编码后再保存");
@@ -58,6 +58,7 @@
    ErrorCode PURCHASE_REQUEST_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_030_104_008, "采购申请审批分类不存在,请先在BPM模块创建采购申请审批流程分类");
    ErrorCode PURCHASE_REQUEST_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_104_009, "采购申请审批分类下没有可用的流程模型,请先创建并发布流程");
    ErrorCode PURCHASE_REQUEST_RESET_FAIL_NOT_REJECT = new ErrorCode(1_030_104_010, "重置失败,只有驳回状态的采购申请单才能重置");
    ErrorCode PURCHASE_REQUEST_SUPPLIER_REQUIRED = new ErrorCode(1_030_104_011, "采购申请单未指定供应商,无法生成采购订单,请先编辑申请单补充供应商");
    // ========== ERP é‡‡è´­æ¥ç¥¨ï¼ˆ1-030-105-000) ==========
    ErrorCode PURCHASE_INVOICE_NOT_EXISTS = new ErrorCode(1_030_105_000, "来票不存在");
@@ -66,6 +67,11 @@
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_ORDER_NOT_APPROVE = new ErrorCode(1_030_105_006, "创建来票失败,原因:采购订单不是审核通过状态");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_SUPPLIER_MISMATCH = new ErrorCode(1_030_105_007, "创建来票失败,原因:来票供应商与采购订单不一致");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT = new ErrorCode(1_030_105_008, "创建来票失败,原因:来票金额超出采购订单金额,目前可开票:{} å…ƒ");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_NOT_EXISTS = new ErrorCode(1_030_105_009, "来票关联的入库单不存在");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_NOT_FINISHED = new ErrorCode(1_030_105_010, "来票关联的入库单未完成入库");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_ORDER_MISMATCH = new ErrorCode(1_030_105_011, "来票关联的入库单与采购订单不一致");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_SUPPLIER_MISMATCH = new ErrorCode(1_030_105_012, "来票关联的入库单与供应商不一致");
    ErrorCode PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_PRICE_EXCEEDS_LIMIT = new ErrorCode(1_030_105_013, "来票金额超出该入库单可开票金额");
    // ========== ERP é‡‡è´­é€€è´§ï¼ˆ1-030-103-000) ==========
    ErrorCode PURCHASE_RETURN_NOT_EXISTS = new ErrorCode(1_030_103_000, "采购退货单不存在");
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.erp.service.purchase;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceItemReceiptRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoicePageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceSaveReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
@@ -100,4 +101,13 @@
     */
    Map<Long, BigDecimal> getPurchaseInvoicePriceSumMapByOrderIds(Collection<Long> orderIds);
    /**
     * èŽ·å¾—æ¥ç¥¨å¯é€‰çš„å…¥åº“å•åˆ—è¡¨ï¼ˆæŒ‰é‡‡è´­è®¢å•è¿‡æ»¤ï¼Œå«å…¥åº“é‡‘é¢ã€å·²æ¥ç¥¨é‡‘é¢ã€å¯æ¥ç¥¨é‡‘é¢ï¼‰
     *
     * @param purchaseOrderId é‡‡è´­è®¢å•编号
     * @param excludeInvoiceId éœ€æŽ’除的来票编号(编辑时排除自身),可为空
     * @return å…¥åº“单列表
     */
    List<ErpPurchaseInvoiceItemReceiptRespVO> getItemReceiptListForInvoice(Long purchaseOrderId, Long excludeInvoiceId);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java
@@ -3,12 +3,19 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceItemReceiptRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoicePageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceSaveReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderItemDO;
import cn.iocoder.yudao.module.mes.api.itemreceipt.MesWmItemReceiptApi;
import cn.iocoder.yudao.module.mes.api.itemreceipt.dto.MesWmItemReceiptLineRespDTO;
import cn.iocoder.yudao.module.mes.api.itemreceipt.dto.MesWmItemReceiptRespDTO;
import cn.iocoder.yudao.module.erp.dal.mysql.finance.ErpFinancePaymentMapper;
import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseInvoiceMapper;
import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO;
@@ -23,6 +30,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -56,7 +64,20 @@
    private ErpPurchaseOrderService purchaseOrderService;
    @Resource
    private MesWmItemReceiptApi mesWmItemReceiptApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    /**
     * MES å…¥åº“单已完成状态,见 MesWmItemReceiptStatusEnum.FINISHED
     */
    private static final Integer ITEM_RECEIPT_STATUS_FINISHED = 4;
    /**
     * MES å…¥åº“单已取消状态,见 MesWmItemReceiptStatusEnum.CANCELED
     */
    private static final Integer ITEM_RECEIPT_STATUS_CANCELED = 5;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -73,6 +94,7 @@
        // 2.1 æ’入来票(创建后即为有效状态,无需审核)
        ErpPurchaseInvoiceDO invoice = BeanUtils.toBean(createReqVO, ErpPurchaseInvoiceDO.class).setNo(no);
        applyItemReceiptCode(invoice);
        invoiceMapper.insert(invoice);
        // 2.2 ç»‘定发票附件
@@ -90,6 +112,65 @@
            throw exception(PURCHASE_INVOICE_CREATE_FAIL_SUPPLIER_MISMATCH);
        }
        reqVO.setSupplierId(purchaseOrder.getSupplierId()); // ä¾›åº”商以采购订单为准
        // æ ¡éªŒå…¥åº“单,并回填入库单号快照
        validateItemReceipt(reqVO, purchaseOrder);
    }
    /**
     * æ ¡éªŒæ¥ç¥¨ç»‘定的入库单:必须存在、已完成入库、属于当前采购订单和供应商
     */
    private void validateItemReceipt(ErpPurchaseInvoiceSaveReqVO reqVO, ErpPurchaseOrderDO purchaseOrder) {
        if (reqVO.getItemReceiptId() == null) {
            return; // å­˜é‡/未绑定时允许为空
        }
        MesWmItemReceiptRespDTO receipt = mesWmItemReceiptApi.getItemReceipt(reqVO.getItemReceiptId());
        if (receipt == null) {
            throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_NOT_EXISTS.getCode(),
                    "来票关联的入库单不存在:入库单 ID=" + reqVO.getItemReceiptId()
                            + "。请重新选择该采购订单下的入库单;若列表中没有可选项,请先在 MES å®Œæˆå…¥åº“。");
        }
        if (!ITEM_RECEIPT_STATUS_FINISHED.equals(receipt.getStatus())) {
            throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_NOT_FINISHED.getCode(),
                    "来票关联的入库单[" + receipt.getCode() + "]当前状态为「" + itemReceiptStatusName(receipt.getStatus())
                            + "」,只有「已完成」的入库单才能来票。请先在 MES å®Œæˆå…¥åº“后再选择该入库单。");
        }
        if (ObjectUtil.notEqual(receipt.getPurchaseOrderId(), reqVO.getPurchaseOrderId())) {
            throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_ORDER_MISMATCH.getCode(),
                    "所选入库单[" + receipt.getCode() + "]属于采购订单[" + StrUtil.blankToDefault(receipt.getPurchaseOrderCode(), "ID=" + receipt.getPurchaseOrderId())
                            + "],与当前选择的采购订单[" + purchaseOrder.getNo() + "]不一致。请重新选择该采购订单下的入库单,或先核对采购订单号。");
        }
        if (ObjectUtil.notEqual(receipt.getVendorId(), purchaseOrder.getSupplierId())) {
            throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_SUPPLIER_MISMATCH.getCode(),
                    "所选入库单[" + receipt.getCode() + "]的供应商(ID=" + receipt.getVendorId()
                            + ")与采购订单[" + purchaseOrder.getNo() + "]的供应商(ID=" + purchaseOrder.getSupplierId()
                            + ")不一致。请核对入库单来源,或改选该采购订单下的入库单。");
        }
    }
    /**
     * ä¿å­˜å…¥åº“单号快照,列表与导出直接展示,避免联表
     */
    private void applyItemReceiptCode(ErpPurchaseInvoiceDO invoice) {
        if (invoice.getItemReceiptId() == null) {
            invoice.setItemReceiptCode(null);
            return;
        }
        MesWmItemReceiptRespDTO receipt = mesWmItemReceiptApi.getItemReceipt(invoice.getItemReceiptId());
        invoice.setItemReceiptCode(receipt != null ? receipt.getCode() : null);
    }
    private String itemReceiptStatusName(Integer status) {
        if (status == null) {
            return "未知";
        }
        return switch (status) {
            case 0 -> "草稿";
            case 2 -> "待上架";
            case 3 -> "待执行入库";
            case 4 -> "已完成";
            case 5 -> "已取消";
            default -> "状态" + status;
        };
    }
    private void validatePurchaseInvoicePriceExceedsLimit(ErpPurchaseInvoiceSaveReqVO reqVO) {
@@ -101,10 +182,67 @@
        }
        BigDecimal remainPrice = purchaseOrder.getTotalPrice().subtract(
                getSumValue(invoices, ErpPurchaseInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO));
        // 2. æ ¡éªŒé‡‘额是否超过
        // 2. æ ¡éªŒé‡‘额是否超过采购订单可开票金额
        if (reqVO.getPrice().compareTo(remainPrice) > 0) {
            throw exception(PURCHASE_INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, remainPrice);
        }
        // 3. ç»‘定了入库单时,还要校验不超过入库单可开票金额(入库金额 - è¯¥å…¥åº“单已来票金额)
        if (reqVO.getItemReceiptId() != null) {
            MesWmItemReceiptRespDTO receipt = mesWmItemReceiptApi.getItemReceipt(reqVO.getItemReceiptId());
            BigDecimal receiptTotalPrice = calcItemReceiptTotalPrice(receipt);
            BigDecimal invoicedPrice = getItemReceiptInvoicedPrice(reqVO.getItemReceiptId(), reqVO.getId());
            BigDecimal receiptRemainPrice = receiptTotalPrice.subtract(invoicedPrice);
            if (reqVO.getPrice().compareTo(receiptRemainPrice) > 0) {
                throw new ServiceException(PURCHASE_INVOICE_CREATE_FAIL_RECEIPT_PRICE_EXCEEDS_LIMIT.getCode(),
                        "本次来票金额 " + reqVO.getPrice().stripTrailingZeros().toPlainString() + " å…ƒï¼Œè¶…过所选入库单["
                                + (receipt != null ? receipt.getCode() : "ID=" + reqVO.getItemReceiptId()) + "]的可开票金额 "
                                + receiptRemainPrice.stripTrailingZeros().toPlainString() + " å…ƒï¼ˆå…¥åº“金额 "
                                + receiptTotalPrice.stripTrailingZeros().toPlainString() + " å…ƒï¼Œå·²æ¥ç¥¨ "
                                + invoicedPrice.stripTrailingZeros().toPlainString()
                                + " å…ƒï¼‰ã€‚请调整来票金额,或改选其他入库单;如需按整单开票,请先核对入库金额。");
            }
        }
    }
    /**
     * è®¡ç®—入库单金额:优先取入库行金额快照,其次按行单价 Ã— å…¥åº“数量,最后按采购订单行单价兜底
     */
    private BigDecimal calcItemReceiptTotalPrice(MesWmItemReceiptRespDTO receipt) {
        if (receipt == null || CollUtil.isEmpty(receipt.getLines())) {
            return BigDecimal.ZERO;
        }
        Map<Long, BigDecimal> orderItemPriceMap = purchaseOrderService
                .getPurchaseOrderItemListByOrderId(receipt.getPurchaseOrderId()).stream()
                .filter(item -> item.getProductPrice() != null)
                .collect(Collectors.toMap(ErpPurchaseOrderItemDO::getId, ErpPurchaseOrderItemDO::getProductPrice, (a, b) -> a));
        BigDecimal total = BigDecimal.ZERO;
        for (MesWmItemReceiptLineRespDTO line : receipt.getLines()) {
            BigDecimal amount = line.getTotalPrice();
            if (amount == null && line.getPrice() != null && line.getReceivedQuantity() != null) {
                amount = line.getPrice().multiply(line.getReceivedQuantity());
            }
            if (amount == null && line.getPurchaseOrderItemId() != null && line.getReceivedQuantity() != null) {
                BigDecimal orderItemPrice = orderItemPriceMap.get(line.getPurchaseOrderItemId());
                if (orderItemPrice != null) {
                    amount = orderItemPrice.multiply(line.getReceivedQuantity());
                }
            }
            if (amount != null) {
                total = total.add(amount);
            }
        }
        return total;
    }
    /**
     * è®¡ç®—某入库单已来票金额(可按来票编号排除自身)
     */
    private BigDecimal getItemReceiptInvoicedPrice(Long itemReceiptId, Long excludeInvoiceId) {
        List<ErpPurchaseInvoiceDO> invoices = invoiceMapper.selectListByItemReceiptId(itemReceiptId);
        if (excludeInvoiceId != null) {
            invoices.removeIf(invoice -> Objects.equals(invoice.getId(), excludeInvoiceId));
        }
        return getSumValue(invoices, ErpPurchaseInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO);
    }
    @Override
@@ -119,6 +257,7 @@
        // 2. æ›´æ–°æ¥ç¥¨
        ErpPurchaseInvoiceDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseInvoiceDO.class);
        applyItemReceiptCode(updateObj);
        invoiceMapper.updateById(updateObj);
        // 3. å¢žé‡æ›´æ–°å‘票附件
@@ -188,4 +327,28 @@
                Collectors.mapping(ErpPurchaseInvoiceDO::getPrice, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))));
    }
    @Override
    public List<ErpPurchaseInvoiceItemReceiptRespVO> getItemReceiptListForInvoice(Long purchaseOrderId, Long excludeInvoiceId) {
        if (purchaseOrderId == null) {
            return List.of();
        }
        List<MesWmItemReceiptRespDTO> receipts = mesWmItemReceiptApi.getItemReceiptListByPurchaseOrderId(purchaseOrderId);
        return receipts.stream()
                .filter(receipt -> !ITEM_RECEIPT_STATUS_CANCELED.equals(receipt.getStatus()))
                .map(receipt -> {
                    BigDecimal totalPrice = calcItemReceiptTotalPrice(receipt);
                    BigDecimal invoicedPrice = getItemReceiptInvoicedPrice(receipt.getId(), excludeInvoiceId);
                    return new ErpPurchaseInvoiceItemReceiptRespVO()
                            .setId(receipt.getId())
                            .setCode(receipt.getCode())
                            .setName(receipt.getName())
                            .setReceiptDate(receipt.getReceiptDate())
                            .setStatus(receipt.getStatus())
                            .setTotalPrice(totalPrice)
                            .setInvoicedPrice(invoicedPrice)
                            .setRemainInvoicePrice(totalPrice.subtract(invoicedPrice));
                })
                .toList();
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
@@ -454,8 +454,12 @@
        if (!submitable) {
            throw exception(PURCHASE_ORDER_SUBMIT_FAIL_STATUS);
        }
        // 2. ä»˜æ¬¾æ—¥æœŸå¿…选:采购申请自动生成的草稿订单没有付款日期,提交前必须先编辑补齐
        if (purchaseOrder.getPaymentDate() == null) {
            throw exception(PURCHASE_ORDER_PAYMENT_DATE_REQUIRED);
        }
        // 2. åˆ›å»º BPM æµç¨‹å®žä¾‹
        // 3. åˆ›å»º BPM æµç¨‹å®žä¾‹
        // å…¨å±€å…å®¡æ‰¹ï¼šä¸šåŠ¡æ ¡éªŒé€šè¿‡åŽç›´æŽ¥æ‰§è¡Œå®¡æ‰¹é€šè¿‡é€»è¾‘ï¼Œæ— éœ€æµç¨‹æ¨¡åž‹ã€‚
        if (processInstanceApi.isGlobalAutoApprove()) {
            purchaseOrderMapper.updateById(new ErpPurchaseOrderDO()
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestServiceImpl.java
@@ -567,11 +567,16 @@
        if (purchaseRequest.getOrderId() != null) {
            throw exception(PURCHASE_REQUEST_ORDER_EXISTS);
        }
        // 3. æ ¡éªŒä¾›åº”商
        supplierService.validateSupplier(supplierId);
        // 3. ä¾›åº”商以采购申请单为准:申请提交时已经选过供应商,生成订单不再要求重新选择
        //    å…¼å®¹æ—§è°ƒç”¨æ–¹ä»æ˜¾å¼ä¼  supplierId çš„æƒ…况,传了则以传入值为准
        Long targetSupplierId = supplierId != null ? supplierId : purchaseRequest.getSupplierId();
        if (targetSupplierId == null) {
            throw exception(PURCHASE_REQUEST_SUPPLIER_REQUIRED);
        }
        supplierService.validateSupplier(targetSupplierId);
        // 4. è®¾ç½®ä¾›åº”商并生成订单
        purchaseRequest.setSupplierId(supplierId);
        purchaseRequest.setSupplierId(targetSupplierId);
        return doGeneratePurchaseOrder(purchaseRequest);
    }
@@ -599,7 +604,9 @@
        // 3. åˆ›å»ºé‡‡è´­è®¢å•
        ErpPurchaseOrderDO purchaseOrder = new ErpPurchaseOrderDO();
        purchaseOrder.setNo(orderNo);
        purchaseOrder.setStatus(ErpAuditStatus.APPROVE.getStatus()); // é‡‡è´­è®¢å•直接为已审核状态
        // ç”ŸæˆåŽä¸ºè‰ç¨¿ï¼šé‡‡è´­ç”³è¯·ä¸Žé‡‡è´­å°è´¦å­—段不完全一致(付款日期、采购类型、付款方式等),
        // éœ€è¦ä¸šåŠ¡åœ¨é‡‡è´­å°è´¦é‡Œè¡¥å……å®Œæ•´åŽå†æäº¤å®¡æ‰¹
        purchaseOrder.setStatus(ErpAuditStatus.DRAFT.getStatus());
        purchaseOrder.setSupplierId(purchaseRequest.getSupplierId());
        purchaseOrder.setOrderTime(purchaseRequest.getRequestTime());
        purchaseOrder.setTotalCount(purchaseRequest.getTotalCount());
@@ -628,7 +635,8 @@
            orderItem.setTaxPrice(item.getTaxPrice());
            orderItem.setRemark(item.getRemark());
            orderItem.setInCount(BigDecimal.ZERO);
            orderItem.setQcCheckFlag(item.getQcCheckFlag() != null ? item.getQcCheckFlag() : false);
            // æ˜¯å¦è´¨æ£€ä¸Žé‡‡è´­ç”³è¯·ä¿æŒä¸€è‡´ï¼šæ˜Žç»†æœªæ˜Žç¡®æ ‡è®°æ—¶é»˜è®¤ä¸ºã€Œæ˜¯ã€ï¼ˆåŽ†å²ç”³è¯·å•å¯èƒ½ä¸º null)
            orderItem.setQcCheckFlag(item.getQcCheckFlag() != null ? item.getQcCheckFlag() : true);
            return orderItem;
        });
        purchaseOrderItemMapper.insertBatch(orderItems);
@@ -636,6 +644,7 @@
        // 5. æ›´æ–°é‡‡è´­ç”³è¯·çš„订单信息
        purchaseRequestMapper.updateById(new ErpPurchaseRequestDO()
                .setId(id)
                .setSupplierId(purchaseRequest.getSupplierId())
                .setOrderId(purchaseOrder.getId())
                .setOrderNo(orderNo));
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpSupplierStatementService.java
@@ -2,7 +2,11 @@
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementSummaryReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementSummaryRespVO;
import jakarta.validation.Valid;
import java.util.List;
/**
 * ERP ä¾›åº”商往来 Service æŽ¥å£
@@ -14,4 +18,9 @@
     */
    ErpSupplierStatementRespVO getSupplierStatement(@Valid ErpSupplierStatementReqVO reqVO);
    /**
     * èŽ·å–å„ä¾›åº”å•†å¾€æ¥æ±‡æ€»åˆ—è¡¨ï¼ˆå·¦ä¾§ä¾›åº”å•†æ€»è§ˆï¼‰ï¼Œå£å¾„ä¸Žå•ä¸ªä¾›åº”å•†è¯¦æƒ…ä¸€è‡´
     */
    List<ErpSupplierStatementSummaryRespVO> getSupplierSummaryList(@Valid ErpSupplierStatementSummaryReqVO reqVO);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpSupplierStatementServiceImpl.java
@@ -1,9 +1,12 @@
package cn.iocoder.yudao.module.erp.service.purchase;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementSummaryReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.statement.ErpSupplierStatementSummaryRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO;
@@ -156,6 +159,99 @@
        return respVO;
    }
    @Override
    public List<ErpSupplierStatementSummaryRespVO> getSupplierSummaryList(ErpSupplierStatementSummaryReqVO reqVO) {
        // 1. å£å¾„与单个供应商详情保持一致:订单按审核状态过滤、来票不过滤状态、付款含未审核/审批中/已审核
        List<Integer> orderStatuses = Boolean.TRUE.equals(reqVO.getIncludeUnapprovedOrder())
                ? List.of(ErpAuditStatus.PROCESS.getStatus(), ErpAuditStatus.APPROVING.getStatus(),
                ErpAuditStatus.APPROVE.getStatus())
                : List.of(ErpAuditStatus.APPROVE.getStatus());
        List<ErpPurchaseOrderDO> orders = purchaseOrderMapper.selectListByOrderTimeBetween(
                reqVO.getBeginTime(), reqVO.getEndTime(), orderStatuses);
        List<ErpPurchaseInvoiceDO> invoices = purchaseInvoiceMapper.selectListByInvoiceTimeBetween(
                reqVO.getBeginTime(), reqVO.getEndTime());
        List<Integer> paymentStatuses = List.of(ErpAuditStatus.PROCESS.getStatus(),
                ErpAuditStatus.APPROVING.getStatus(), ErpAuditStatus.APPROVE.getStatus());
        List<ErpFinancePaymentDO> payments = financePaymentMapper.selectListByPaymentTimeBetween(
                reqVO.getBeginTime(), reqVO.getEndTime(), paymentStatuses);
        // 2. æŒ‰ä¾›åº”商汇总
        Map<Long, ErpSupplierStatementSummaryRespVO> summaryMap = new HashMap<>();
        for (ErpPurchaseOrderDO order : orders) {
            if (order.getSupplierId() == null) {
                continue;
            }
            ErpSupplierStatementSummaryRespVO summary = getOrCreateSummary(summaryMap, order.getSupplierId());
            summary.setOrderCount(summary.getOrderCount() + 1);
            summary.setTotalPurchaseAmount(summary.getTotalPurchaseAmount().add(zero(order.getTotalPrice())));
        }
        for (ErpPurchaseInvoiceDO invoice : invoices) {
            if (invoice.getSupplierId() == null) {
                continue;
            }
            ErpSupplierStatementSummaryRespVO summary = getOrCreateSummary(summaryMap, invoice.getSupplierId());
            summary.setInvoiceCount(summary.getInvoiceCount() + 1);
            summary.setTotalInvoiceAmount(summary.getTotalInvoiceAmount().add(zero(invoice.getPrice())));
        }
        for (ErpFinancePaymentDO payment : payments) {
            if (payment.getSupplierId() == null) {
                continue;
            }
            ErpSupplierStatementSummaryRespVO summary = getOrCreateSummary(summaryMap, payment.getSupplierId());
            summary.setPaymentCount(summary.getPaymentCount() + 1);
            summary.setTotalPaymentAmount(summary.getTotalPaymentAmount().add(zero(payment.getPaymentPrice())));
        }
        // 3. å¡«å……供应商编码 / åç§°ï¼ˆSRM ä¸­å·²åˆ é™¤çš„供应商用 ID å…œåº•展示,避免出现空行)
        Map<Long, SrmSupplierRespDTO> supplierMap = new HashMap<>();
        if (!summaryMap.isEmpty()) {
            List<SrmSupplierRespDTO> suppliers = srmSupplierApi.getSupplierList(summaryMap.keySet()).getCheckedData();
            if (CollUtil.isNotEmpty(suppliers)) {
                suppliers.forEach(item -> supplierMap.put(item.getId(), item));
            }
        }
        for (ErpSupplierStatementSummaryRespVO summary : summaryMap.values()) {
            summary.setUnpaidAmount(summary.getTotalInvoiceAmount().subtract(summary.getTotalPaymentAmount()));
            SrmSupplierRespDTO supplier = supplierMap.get(summary.getSupplierId());
            if (supplier == null) {
                summary.setSupplierName("未知供应商(ID=" + summary.getSupplierId() + ")");
                continue;
            }
            summary.setSupplierCode(supplier.getCode());
            summary.setSupplierName(StrUtil.blankToDefault(supplier.getName(),
                    "未命名供应商(ID=" + summary.getSupplierId() + ")"));
        }
        // 4. æœªä»˜ä½™é¢å¤šçš„æŽ’前面,其次按采购额、供应商名称
        Comparator<ErpSupplierStatementSummaryRespVO> comparator =
                Comparator.comparing(ErpSupplierStatementSummaryRespVO::getUnpaidAmount,
                                Comparator.nullsFirst(Comparator.<BigDecimal>naturalOrder()))
                        .thenComparing(ErpSupplierStatementSummaryRespVO::getTotalPurchaseAmount,
                                Comparator.nullsFirst(Comparator.<BigDecimal>naturalOrder()))
                        .reversed()
                        .thenComparing(ErpSupplierStatementSummaryRespVO::getSupplierName,
                                Comparator.nullsLast(Comparator.naturalOrder()));
        return summaryMap.values().stream()
                .sorted(comparator)
                .collect(Collectors.toList());
    }
    private ErpSupplierStatementSummaryRespVO getOrCreateSummary(
            Map<Long, ErpSupplierStatementSummaryRespVO> summaryMap, Long supplierId) {
        return summaryMap.computeIfAbsent(supplierId, id -> {
            ErpSupplierStatementSummaryRespVO summary = new ErpSupplierStatementSummaryRespVO();
            summary.setSupplierId(id);
            summary.setOrderCount(0);
            summary.setInvoiceCount(0);
            summary.setPaymentCount(0);
            summary.setTotalPurchaseAmount(BigDecimal.ZERO);
            summary.setTotalInvoiceAmount(BigDecimal.ZERO);
            summary.setTotalPaymentAmount(BigDecimal.ZERO);
            summary.setUnpaidAmount(BigDecimal.ZERO);
            return summary;
        });
    }
    private BigDecimal zero(BigDecimal value) {
        return value != null ? value : BigDecimal.ZERO;
    }
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/voucher/vo/FinVoucherPageReqVO.java
@@ -33,4 +33,13 @@
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
    private LocalDate[] voucherDate;
    @Schema(description = "来源单据类型", example = "CRM_INVOICE")
    private String sourceType;
    @Schema(description = "来源单据编号", example = "1024")
    private Long sourceId;
    @Schema(description = "来源单据号", example = "KP20261029001")
    private String sourceNo;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/voucher/vo/FinVoucherRespVO.java
@@ -71,6 +71,17 @@
    @ExcelProperty("备注")
    private String remark;
    @Schema(description = "来源单据类型", example = "CRM_INVOICE")
    @ExcelProperty("来源单据类型")
    private String sourceType;
    @Schema(description = "来源单据编号", example = "1024")
    private Long sourceId;
    @Schema(description = "来源单据号", example = "KP20261029001")
    @ExcelProperty("来源单据号")
    private String sourceNo;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    // ã€Œyyyy-MM-dd HH:mm:ss」正好 19 ä¸ªå­—符,自适应列宽就只给 19,贴着边很难看,这里放宽到 22
    @ColumnWidth(22)
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/voucher/vo/FinVoucherSaveReqVO.java
@@ -48,4 +48,13 @@
    @Schema(description = "附件编号数组", example = "[]")
    private List<Long> blobIds;
    @Schema(description = "来源单据类型:CRM_INVOICE开票、CRM_RECEIVABLE回款、ERP_PURCHASE_INVOICE来票、ERP_PAYMENT付款", example = "CRM_INVOICE")
    private String sourceType;
    @Schema(description = "来源单据编号", example = "1024")
    private Long sourceId;
    @Schema(description = "来源单据号", example = "KP20261029001")
    private String sourceNo;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/voucher/FinVoucherDO.java
@@ -68,4 +68,21 @@
     */
    private String remark;
    /**
     * æ¥æºå•据类型
     *
     * ä¾‹å¦‚:CRM_INVOICE、CRM_RECEIVABLE、ERP_PURCHASE_INVOICE、ERP_PAYMENT
     */
    private String sourceType;
    /**
     * æ¥æºå•据编号
     */
    private Long sourceId;
    /**
     * æ¥æºå•据号(快照)
     */
    private String sourceNo;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/mysql/voucher/FinVoucherMapper.java
@@ -31,6 +31,9 @@
                // å¦åˆ™ã€Œèµµç£Šã€è¿™ç§æ˜µç§°æ°¸è¿œåŒ¹é…ä¸ä¸Šï¼›Service å·²ç»æŠŠæœç´¢è¯è§£æžæˆç¼–号集合
                .inIfPresent(FinVoucherDO::getCreator, creatorIds)
                .eqIfPresent(FinVoucherDO::getStatus, reqVO.getStatus())
                .eqIfPresent(FinVoucherDO::getSourceType, reqVO.getSourceType())
                .eqIfPresent(FinVoucherDO::getSourceId, reqVO.getSourceId())
                .likeIfPresent(FinVoucherDO::getSourceNo, reqVO.getSourceNo())
                .betweenIfPresent(FinVoucherDO::getVoucherDate, reqVO.getVoucherDate())
                .orderByDesc(FinVoucherDO::getVoucherDate)
                .orderByDesc(FinVoucherDO::getId));
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -58,7 +58,19 @@
    ErrorCode RESIGNATION_APPLICATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_030_003_003, "更新离职申请审批状态失败,原因:不是审批中状态");
    ErrorCode RESIGNATION_APPLICATION_NO_EXISTS = new ErrorCode(1_030_003_004, "生成离职申请单号重复,请重试");
    ErrorCode RESIGNATION_APPLICATION_EXISTS = new ErrorCode(1_030_003_005, "该员工已存在离职申请,请勿重复提交");
    ErrorCode RESIGNATION_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_003_006, "离职审批流程不存在");
    ErrorCode RESIGNATION_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_003_006, "离职审批流程不存在,请先在协同办公中配置并发布离职审批流程");
    ErrorCode RESIGNATION_APPLICATION_CANCEL_FAIL_STATUS = new ErrorCode(1_030_003_007, "离职申请撤销失败,原因:仅草稿或审批中状态可以撤销");
    ErrorCode RESIGNATION_HANDOVER_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_003_008, "交接员工不存在,请重新选择交接员工");
    ErrorCode RESIGNATION_HANDOVER_EMPLOYEE_NO_USER = new ErrorCode(1_030_003_009, "交接员工未关联系统用户,无法转移用户数据");
    ErrorCode RESIGNATION_HANDOVER_SELF = new ErrorCode(1_030_003_010, "交接人不能是申请人本人,请选择其他员工");
    ErrorCode RESIGNATION_HANDOVER_EMPLOYEE_RESIGNED = new ErrorCode(1_030_003_011, "交接员工已离职,请重新选择在职员工");
    ErrorCode RESIGNATION_HANDOVER_TARGET_REQUIRED = new ErrorCode(1_030_003_012, "未指定交接员工,无法开始或完成工作交接");
    ErrorCode RESIGNATION_HANDOVER_COMPLETED_CANNOT_REVERT = new ErrorCode(1_030_003_013, "交接已完成,不能回退交接状态");
    ErrorCode RESIGNATION_HANDOVER_NOT_APPROVED = new ErrorCode(1_030_003_014, "离职申请审批通过后才能进行工作交接");
    ErrorCode RESIGNATION_HANDOVER_NOT_STARTED = new ErrorCode(1_030_003_015, "工作交接尚未开始,不能直接完成");
    ErrorCode RESIGNATION_HANDOVER_CHANGE_AFTER_START = new ErrorCode(1_030_003_016, "工作交接已开始,不能更换交接员工");
    ErrorCode RESIGNATION_APPLICANT_NOT_EXISTS = new ErrorCode(1_030_003_017, "申请人不存在,请重新选择申请人");
    ErrorCode RESIGNATION_HANDOVER_STATUS_INVALID = new ErrorCode(1_030_003_018, "交接状态值不合法,仅支持 0未开始、10进行中、20已完成");
    // ========== åŠ ç­ç”³è¯· 1-030-008-000 ==========
    ErrorCode OVERTIME_APPLICATION_NOT_EXISTS = new ErrorCode(1_030_008_000, "加班申请不存在");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/HrmResignationApplicationController.java
@@ -90,6 +90,16 @@
        return success(true);
    }
    @PostMapping("/cancel")
    @Operation(summary = "撤销离职申请")
    @Parameter(name = "id", description = "申请ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:resignation:cancel')")
    public CommonResult<Boolean> cancelResignationApplication(@RequestParam("id") Long id) {
        resignationApplicationService.cancelResignationApplication(id,
                SecurityFrameworkUtils.getLoginUserId());
        return success(true);
    }
    @GetMapping("/approve-process-definition-list")
    @Operation(summary = "获取可用的审批流程定义列表")
    public CommonResult<List<Map<String, Object>>> getApproveProcessDefinitionList() {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/approval/vo/HrmResignationApplicationRespVO.java
@@ -24,6 +24,15 @@
    @Schema(description = "申请人姓名", example = "张三")
    private String userName;
    @Schema(description = "申请人员工ID", example = "1")
    private Long employeeId;
    @Schema(description = "申请人员工姓名", example = "张三")
    private String employeeName;
    @Schema(description = "申请人岗位名称", example = "销售经理")
    private String postName;
    @Schema(description = "部门ID", example = "1")
    private Long deptId;
@@ -60,6 +69,9 @@
    @Schema(description = "用户数据交接状态:0未开始 10进行中 20已完成")
    private Integer handoverDataStatus;
    @Schema(description = "用户数据交接状态名称", example = "未开始")
    private String handoverDataStatusName;
    @Schema(description = "交接状态名称", example = "未开始")
    private String handoverStatusName;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeController.java
@@ -16,6 +16,7 @@
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSimpleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeStatisticsRespVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
@@ -170,6 +171,12 @@
        }
    }
    @GetMapping("/tenure-statistics")
    @Operation(summary = "获得员工人力统计", description = "工龄、年龄、学历、性别、部门工龄等统计,用于人力资源统计页面")
    public CommonResult<HrmEmployeeStatisticsRespVO> getTenureStatistics() {
        return success(employeeService.getTenureStatistics());
    }
    @GetMapping("/simple-list")
    @Operation(summary = "获得员工精简列表", description = "用于下拉选择")
    public CommonResult<List<HrmEmployeeSimpleRespVO>> getEmployeeSimpleList(
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeRespVO.java
@@ -25,6 +25,9 @@
    @ExcelProperty("员工编号")
    private String employeeNo;
    @Schema(description = "关联的系统用户ID")
    private Long userId;
    @Schema(description = "部门ID")
    private Long deptId;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeStatisticsRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,152 @@
package cn.iocoder.yudao.module.hrm.controller.admin.employee.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - å‘˜å·¥äººåŠ›ç»Ÿè®¡ Response VO")
@Data
public class HrmEmployeeStatisticsRespVO {
    @Schema(description = "在岗人数(在职 + è¯•用)", example = "128")
    private Integer totalCount;
    @Schema(description = "在职人数", example = "120")
    private Integer activeCount;
    @Schema(description = "试用人数", example = "8")
    private Integer probationCount;
    @Schema(description = "离职人数", example = "15")
    private Integer resignedCount;
    @Schema(description = "在岗员工平均工龄(年,保留 1 ä½å°æ•°ï¼‰", example = "3.5")
    private Double averageTenureYears;
    @Schema(description = "在岗员工平均年龄(岁,保留 1 ä½å°æ•°ï¼‰", example = "31.2")
    private Double averageAge;
    @Schema(description = "本年入职人数", example = "12")
    private Integer thisYearHireCount;
    @Schema(description = "本年离职人数", example = "4")
    private Integer thisYearResignCount;
    @Schema(description = "工龄分布(固定区间,含 0 äººçš„区间)")
    private List<ItemVO> tenureDistribution;
    @Schema(description = "年龄分布(固定区间,含 0 äººçš„区间)")
    private List<ItemVO> ageDistribution;
    @Schema(description = "学历分布")
    private List<ItemVO> educationDistribution;
    @Schema(description = "性别分布")
    private List<ItemVO> genderDistribution;
    @Schema(description = "人员状态构成(在职 / è¯•用 / ç¦»èŒï¼‰")
    private List<ItemVO> statusDistribution;
    @Schema(description = "部门人数分布,按人数倒序")
    private List<ItemVO> deptDistribution;
    @Schema(description = "岗位分布,按人数倒序")
    private List<ItemVO> postDistribution;
    @Schema(description = "婚姻状况分布")
    private List<ItemVO> marriageDistribution;
    @Schema(description = "政治面貌分布")
    private List<ItemVO> politicalDistribution;
    @Schema(description = "近 12 ä¸ªæœˆå…¥èŒ / ç¦»èŒè¶‹åŠ¿ï¼ŒæŒ‰æœˆä»½å‡åº")
    private List<TrendVO> hireResignTrend;
    @Schema(description = "合同到期预警(已到期 / 30天内 / 31-90天 / 90天以上 / æ— å›ºå®šæœŸé™ï¼‰")
    private List<ItemVO> contractWarning;
    @Schema(description = "在岗未签有效合同人数", example = "3")
    private Integer noContractCount;
    @Schema(description = "各部门工龄统计,按平均工龄倒序")
    private List<DeptTenureVO> deptTenureList;
    @Schema(description = "在职员工工龄排行(工龄最长的前 10 åï¼‰")
    private List<TenureEmployeeVO> topTenureEmployees;
    @Schema(description = "分布项")
    @Data
    public static class ItemVO {
        @Schema(description = "名称", example = "3-5å¹´")
        private String name;
        @Schema(description = "人数", example = "30")
        private Integer value;
        public ItemVO() {
        }
        public ItemVO(String name, Integer value) {
            this.name = name;
            this.value = value;
        }
    }
    @Schema(description = "部门工龄统计")
    @Data
    public static class DeptTenureVO {
        @Schema(description = "部门编号")
        private Long deptId;
        @Schema(description = "部门名称", example = "研发部")
        private String deptName;
        @Schema(description = "部门在岗人数", example = "20")
        private Integer count;
        @Schema(description = "部门平均工龄(年,保留 1 ä½å°æ•°ï¼‰", example = "4.2")
        private Double averageTenureYears;
    }
    @Schema(description = "入职 / ç¦»èŒè¶‹åŠ¿é¡¹")
    @Data
    public static class TrendVO {
        @Schema(description = "月份,格式 yyyy-MM", example = "2026-11")
        private String month;
        @Schema(description = "入职人数", example = "3")
        private Integer hireCount;
        @Schema(description = "离职人数", example = "1")
        private Integer resignCount;
    }
    @Schema(description = "员工工龄明细")
    @Data
    public static class TenureEmployeeVO {
        @Schema(description = "员工编号")
        private Long id;
        @Schema(description = "员工姓名", example = "张敏")
        private String name;
        @Schema(description = "部门名称", example = "研发部")
        private String deptName;
        @Schema(description = "入职日期")
        private LocalDate hireDate;
        @Schema(description = "工龄(年,保留 1 ä½å°æ•°ï¼‰", example = "6.5")
        private Double tenureYears;
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/handover/HrmUserHandoverDO.java
@@ -30,6 +30,13 @@
    private Long id;
    /**
     * ç¦»èŒå‘˜å·¥ID
     *
     * å…³è” HrmEmployeeDO#getId()
     */
    private Long fromEmployeeId;
    /**
     * ç¦»èŒç”¨æˆ·ID
     *
     * å…³è” AdminUserDO#getId()
@@ -37,6 +44,13 @@
    private Long fromUserId;
    /**
     * äº¤æŽ¥ç›®æ ‡å‘˜å·¥ID
     *
     * å…³è” HrmEmployeeDO#getId()
     */
    private Long toEmployeeId;
    /**
     * äº¤æŽ¥ç›®æ ‡ç”¨æˆ·ID
     *
     * å…³è” AdminUserDO#getId()
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/approval/HrmResignationApplicationMapper.java
@@ -7,6 +7,9 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmResignationApplicationDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
import java.util.List;
/**
 * ç¦»èŒç”³è¯· Mapper
 *
@@ -40,4 +43,15 @@
                .ne(HrmResignationApplicationDO::getStatus, status));
    }
    /**
     * æŸ¥è¯¢æŒ‡å®šå®¡æ‰¹çŠ¶æ€ä¸‹ï¼Œé¢„è®¡ç¦»èŒæ—¥æœŸå·²åˆ°ä¸”å°šæœªè®°å½•å®žé™…ç¦»èŒæ—¥æœŸçš„ç”³è¯·
     */
    default List<HrmResignationApplicationDO> selectListByStatusAndExpectedDateLe(Integer status, LocalDate date) {
        return selectList(new LambdaQueryWrapperX<HrmResignationApplicationDO>()
                .eq(HrmResignationApplicationDO::getStatus, status)
                .le(HrmResignationApplicationDO::getExpectedDate, date)
                .isNull(HrmResignationApplicationDO::getActualDate)
                .orderByAsc(HrmResignationApplicationDO::getId));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java
@@ -6,8 +6,10 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeePageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
@@ -95,4 +97,14 @@
        return selectCount(HrmEmployeeDO::getSalaryStructureId, salaryStructureId);
    }
    /**
     * æŒ‰ç³»ç»Ÿç”¨æˆ·ç¼–号更新员工离职状态与离职日期
     */
    default int updateEmployeeStatusByUserId(Long userId, Integer employeeStatus, LocalDate leaveDate) {
        return update(null, new LambdaUpdateWrapper<HrmEmployeeDO>()
                .eq(HrmEmployeeDO::getUserId, userId)
                .set(HrmEmployeeDO::getEmployeeStatus, employeeStatus)
                .set(HrmEmployeeDO::getLeaveDate, leaveDate));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/job/approval/HrmResignationAutoLeaveJob.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.hrm.job.approval;
import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
import cn.iocoder.yudao.module.hrm.service.approval.HrmResignationApplicationService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
/**
 * ç¦»èŒç”³è¯·åˆ°æœŸè‡ªåŠ¨ç¦»èŒ Job
 * <p>
 * æ‰«æå®¡æ‰¹é€šè¿‡ä¸”预计离职日期已到的离职申请,将对应员工状态置为离职并记录实际离职日期。
 *
 * @author è¶…级管理员
 */
@Component
public class HrmResignationAutoLeaveJob implements JobHandler {
    @Resource
    private HrmResignationApplicationService resignationApplicationService;
    @Override
    @TenantJob
    public String execute(String param) {
        int count = resignationApplicationService.autoLeaveResignationApplication();
        return String.format("到期自动离职 %s äºº", count);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmResignationApplicationService.java
@@ -113,4 +113,21 @@
     */
    void updateHandoverStatus(@Valid HrmResignationHandoverReqVO reqVO);
    /**
     * æ’¤é”€ç¦»èŒç”³è¯·
     * <p>
     * è‰ç¨¿ç›´æŽ¥ç½®ä¸ºå·²æ’¤é”€ï¼›å®¡æ‰¹ä¸­åˆ™æ’¤é”€ BPM æµç¨‹å®žä¾‹åŽç½®ä¸ºå·²æ’¤é”€
     *
     * @param id     ç”³è¯·ID
     * @param userId æ“ä½œäººID
     */
    void cancelResignationApplication(Long id, Long userId);
    /**
     * æ‰«æåˆ°è¾¾é¢„计离职日期的已通过离职申请,将员工状态自动置为离职
     *
     * @return æœ¬æ¬¡å¤„理的申请数量
     */
    int autoLeaveResignationApplication();
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/approval/HrmResignationApplicationServiceImpl.java
@@ -2,25 +2,37 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceCancelReqVO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmResignationApplicationPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmResignationApplicationRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmResignationApplicationSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.approval.vo.HrmResignationHandoverReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmResignationApplicationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmResignationApplicationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.hrm.enums.HrmAuditStatusEnum;
import cn.iocoder.yudao.module.hrm.enums.HrmResignationTypeEnum;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import cn.iocoder.yudao.module.hrm.service.handover.HrmUserHandoverService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.PostApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
@@ -31,6 +43,8 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -59,12 +73,17 @@
    private static final Integer HANDOVER_STATUS_IN_PROGRESS = 10;
    private static final Integer HANDOVER_STATUS_COMPLETED = 20;
    /** å‘˜å·¥çŠ¶æ€ï¼šç¦»èŒ */
    private static final Integer EMPLOYEE_STATUS_RESIGNED = 3;
    @Resource
    private HrmResignationApplicationMapper resignationApplicationMapper;
    @Resource
    private HrmNoRedisDAO noRedisDAO;
    @Resource
    private DeptApi deptApi;
    @Resource
    private PostApi postApi;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
@@ -76,31 +95,40 @@
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Resource
    private BpmProcessInstanceService bpmProcessInstanceService;
    @Resource
    private HrmUserHandoverService handoverService;
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private HrmEmployeeService employeeService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createResignationApplication(HrmResignationApplicationSaveReqVO createReqVO) {
        Long userId = createReqVO.getUserId();
        if (userId == null) {
            userId = getCurrentUserId();
        Long userId = createReqVO.getUserId() != null ? createReqVO.getUserId() : getCurrentUserId();
        AdminUserRespDTO user = adminUserApi.getUser(userId);
        if (user == null) {
            throw new ServiceException(RESIGNATION_APPLICANT_NOT_EXISTS.getCode(),
                    String.format("申请人(用户ID=%s)不存在,请重新选择申请人后重试", userId));
        }
        HrmResignationApplicationDO existing = resignationApplicationMapper.selectByUserIdAndStatusNot(userId,
                HrmAuditStatusEnum.CANCEL.getStatus());
        if (existing != null) {
            throw exception(RESIGNATION_APPLICATION_EXISTS);
        }
        String no = generateUniqueNo();
        AdminUserRespDTO user = adminUserApi.getUser(userId);
        if (user == null) {
            userId = getCurrentUserId();
            throw new ServiceException(RESIGNATION_APPLICATION_EXISTS.getCode(),
                    String.format("申请人[%s]已存在未撤销的离职申请(单号=%s,状态=%s),请先撤销或删除原申请后再新建",
                            user.getNickname(), existing.getNo(),
                            HrmAuditStatusEnum.getNameByStatus(existing.getStatus())));
        }
        HrmResignationApplicationDO application = BeanUtils.toBean(createReqVO, HrmResignationApplicationDO.class);
        application.setNo(no);
        application.setNo(generateUniqueNo());
        application.setUserId(userId);
        application.setDeptId(createReqVO.getDeptId() != null ? createReqVO.getDeptId() : user.getDeptId());
        application.setStatus(HrmAuditStatusEnum.DRAFT.getStatus());
        application.setHandoverStatus(HANDOVER_STATUS_NOT_STARTED);
        application.setHandoverDataStatus(HANDOVER_STATUS_NOT_STARTED);
        // ç»Ÿä¸€è§£æžäº¤æŽ¥å‘˜å·¥ä¸Žç³»ç»Ÿç”¨æˆ·ï¼Œç¡®ä¿å‘˜å·¥ID、用户ID成对落库
        resolveHandoverTarget(application, userId);
        resignationApplicationMapper.insert(application);
        storageAttachmentApi.bindAttachments("file", "hrm_resignation_application",
                application.getId(), createReqVO.getBlobIds());
@@ -108,7 +136,7 @@
    }
    private Long getCurrentUserId() {
        return 1L;
        return SecurityFrameworkUtils.getLoginUserId();
    }
    private String generateUniqueNo() {
@@ -126,6 +154,22 @@
            throw exception(RESIGNATION_APPLICATION_UPDATE_FAIL_NOT_DRAFT);
        }
        HrmResignationApplicationDO updateObj = BeanUtils.toBean(updateReqVO, HrmResignationApplicationDO.class);
        // ä¸è¦†ç›–申请人;交接人统一解析成“员工ID + ç”¨æˆ·ID”
        updateObj.setUserId(null);
        boolean handoverProvided = updateReqVO.getHandoverEmployeeId() != null
                || updateReqVO.getHandoverUserId() != null;
        if (handoverProvided) {
            resolveHandoverTarget(updateObj, application.getUserId());
            Integer currentHandoverStatus = application.getHandoverStatus() != null
                    ? application.getHandoverStatus() : HANDOVER_STATUS_NOT_STARTED;
            if (ObjUtil.notEqual(currentHandoverStatus, HANDOVER_STATUS_NOT_STARTED)
                    && !Objects.equals(updateObj.getHandoverEmployeeId(), application.getHandoverEmployeeId())) {
                throw new ServiceException(RESIGNATION_HANDOVER_CHANGE_AFTER_START.getCode(),
                        String.format("当前交接状态为[%s],交接人[%s]不能再更换;请先将交接状态回退为[未开始];若已完成交接,请重新提交离职申请",
                                getHandoverStatusName(currentHandoverStatus),
                                getEmployeeDisplayName(application.getHandoverEmployeeId())));
            }
        }
        resignationApplicationMapper.updateById(updateObj);
        storageAttachmentApi.updateAttachments("file", "hrm_resignation_application", updateReqVO.getId(), updateReqVO.getBlobIds());
    }
@@ -161,6 +205,7 @@
        }
        HrmResignationApplicationRespVO respVO = BeanUtils.toBean(application, HrmResignationApplicationRespVO.class);
        fillUserNames(respVO, application);
        fillApplicantEmployeeInfo(respVO, application);
        if (application.getDeptId() != null) {
            DeptRespDTO dept = deptApi.getDept(application.getDeptId());
            if (dept != null) {
@@ -169,14 +214,15 @@
        }
        respVO.setResignationTypeName(HrmResignationTypeEnum.getNameByStatus(application.getResignationType()));
        respVO.setHandoverStatusName(getHandoverStatusName(application.getHandoverStatus()));
        respVO.setHandoverDataStatusName(getHandoverStatusName(application.getHandoverDataStatus()));
        respVO.setStatusName(HrmAuditStatusEnum.getNameByStatus(application.getStatus()));
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("hrm_resignation_application", id));
        return respVO;
    }
    private void fillUserNames(HrmResignationApplicationRespVO respVO, HrmResignationApplicationDO application) {
        Set<Long> userIds = Set.of(application.getUserId(), application.getHandoverUserId())
                .stream().filter(Objects::nonNull).collect(Collectors.toSet());
        Set<Long> userIds = Stream.of(application.getUserId(), application.getHandoverUserId())
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
        if (application.getUserId() != null && userMap.containsKey(application.getUserId())) {
            respVO.setUserName(userMap.get(application.getUserId()).getNickname());
@@ -184,6 +230,34 @@
        if (application.getHandoverUserId() != null && userMap.containsKey(application.getHandoverUserId())) {
            respVO.setHandoverUserName(userMap.get(application.getHandoverUserId()).getNickname());
        }
    }
    /**
     * å¡«å……申请人员工信息:员工ID ä¸Žå²—位名称,供前端回显"申请人 + å²—位"
     */
    private void fillApplicantEmployeeInfo(HrmResignationApplicationRespVO respVO,
                                           HrmResignationApplicationDO application) {
        if (application.getUserId() == null) {
            return;
        }
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(application.getUserId());
        if (employee == null) {
            return;
        }
        respVO.setEmployeeId(employee.getId());
        respVO.setEmployeeName(employee.getName());
        if (StrUtil.isBlank(employee.getPostIds())) {
            return;
        }
        List<Long> postIds = JSONUtil.toList(employee.getPostIds(), Long.class);
        if (CollUtil.isEmpty(postIds)) {
            return;
        }
        Map<Long, PostRespDTO> postMap = postApi.getPostMap(postIds);
        respVO.setPostName(postIds.stream()
                .filter(postMap::containsKey)
                .map(id -> postMap.get(id).getName())
                .collect(Collectors.joining(",")));
    }
    private String getHandoverStatusName(Integer handoverStatus) {
@@ -223,6 +297,13 @@
            if (app.getUserId() != null && userMap.containsKey(app.getUserId())) {
                vo.setUserName(userMap.get(app.getUserId()).getNickname());
            }
            // ç”³è¯·äººå‘˜å·¥ä¿¡æ¯ï¼šå‘˜å·¥æœªå»ºæ¡£æ—¶ä¿æŒä¸ºç©º
            HrmEmployeeDO applicantEmployee = app.getUserId() == null ? null
                    : employeeService.getEmployeeByUserId(app.getUserId());
            if (applicantEmployee != null) {
                vo.setEmployeeId(applicantEmployee.getId());
                vo.setEmployeeName(applicantEmployee.getName());
            }
            if (app.getHandoverUserId() != null && userMap.containsKey(app.getHandoverUserId())) {
                vo.setHandoverUserName(userMap.get(app.getHandoverUserId()).getNickname());
            }
@@ -231,6 +312,7 @@
            }
            vo.setResignationTypeName(HrmResignationTypeEnum.getNameByStatus(app.getResignationType()));
            vo.setHandoverStatusName(getHandoverStatusName(app.getHandoverStatus()));
            vo.setHandoverDataStatusName(getHandoverStatusName(app.getHandoverDataStatus()));
            vo.setStatusName(HrmAuditStatusEnum.getNameByStatus(app.getStatus()));
            return vo;
        }).collect(Collectors.toList());
@@ -255,13 +337,9 @@
            return;
        }
        validateApproveCategoryAndProcessDefinition();
        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (processDefinition == null) {
            throw exception(RESIGNATION_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        ProcessDefinition processDefinition = resolveActiveProcessDefinition(processDefinitionKey);
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(processDefinitionKey).setBusinessKey(String.valueOf(id)));
                .setProcessDefinitionKey(processDefinition.getKey()).setBusinessKey(String.valueOf(id)));
        resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                .id(id)
                .processInstanceId(processInstanceId)
@@ -286,6 +364,45 @@
        if (!hasActiveDefinition) {
            throw exception(RESIGNATION_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
    }
    /**
     * è§£æžç¦»èŒå®¡æ‰¹å¯ç”¨çš„æµç¨‹å®šä¹‰
     * <p>
     * å‰ç«¯åŽ†å²ç‰ˆæœ¬æŠŠåˆ†ç±»ç¼–ç (hrm_resignation_approve)当作流程 Key ä¼ å…¥ï¼Œ
     * è¿™é‡Œä¼˜å…ˆä½¿ç”¨ä¼ å…¥ Key,匹配不到时回退到标准 Key,再回退到该分类下唯一的激活流程。
     */
    private ProcessDefinition resolveActiveProcessDefinition(String processDefinitionKey) {
        validateApproveCategoryAndProcessDefinition();
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(APPROVE_CATEGORY_CODE);
        Set<String> processDefinitionIds = convertSet(definitionInfoList, BpmProcessDefinitionInfoDO::getProcessDefinitionId);
        List<ProcessDefinition> processDefinitions = bpmProcessDefinitionService.getProcessDefinitionList(processDefinitionIds);
        Map<String, ProcessDefinition> latestVersionMap = new HashMap<>();
        for (ProcessDefinition pd : processDefinitions) {
            if (pd.isSuspended()) {
                continue;
            }
            ProcessDefinition existing = latestVersionMap.get(pd.getKey());
            if (existing == null || pd.getVersion() > existing.getVersion()) {
                latestVersionMap.put(pd.getKey(), pd);
            }
        }
        if (latestVersionMap.isEmpty()) {
            throw exception(RESIGNATION_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        if (StrUtil.isNotBlank(processDefinitionKey) && latestVersionMap.containsKey(processDefinitionKey)) {
            return latestVersionMap.get(processDefinitionKey);
        }
        if (latestVersionMap.containsKey(BPM_PROCESS_DEFINITION_KEY)) {
            return latestVersionMap.get(BPM_PROCESS_DEFINITION_KEY);
        }
        if (latestVersionMap.size() == 1) {
            return latestVersionMap.values().iterator().next();
        }
        throw new ServiceException(RESIGNATION_APPLICATION_BPM_PROCESS_DEFINITION_NOT_EXISTS.getCode(),
                String.format("离职审批流程不唯一(分类=%s,可用Key=%s),请传入具体的流程定义Key后重试",
                        APPROVE_CATEGORY_CODE, String.join(",", latestVersionMap.keySet())));
    }
    @Override
@@ -338,18 +455,233 @@
    @Transactional(rollbackFor = Exception.class)
    public void updateHandoverStatus(HrmResignationHandoverReqVO reqVO) {
        HrmResignationApplicationDO application = validateResignationApplicationExists(reqVO.getId());
        resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                .id(reqVO.getId())
                .handoverStatus(reqVO.getHandoverStatus())
                .build());
        // äº¤æŽ¥å®Œæˆæ—¶ï¼Œåˆ›å»ºç”¨æˆ·æ•°æ®äº¤æŽ¥æ˜ å°„
        if (Objects.equals(reqVO.getHandoverStatus(), HANDOVER_STATUS_COMPLETED)
                && application.getHandoverUserId() != null) {
            handoverService.createHandover(
                    application.getUserId(),
                    application.getHandoverUserId(),
                    application.getId());
        Integer targetStatus = reqVO.getHandoverStatus();
        if (!Objects.equals(targetStatus, HANDOVER_STATUS_NOT_STARTED)
                && !Objects.equals(targetStatus, HANDOVER_STATUS_IN_PROGRESS)
                && !Objects.equals(targetStatus, HANDOVER_STATUS_COMPLETED)) {
            throw new ServiceException(RESIGNATION_HANDOVER_STATUS_INVALID.getCode(),
                    String.format("交接状态值[%s]不合法,仅支持 0(未开始)、10(进行中)、20(已完成)", targetStatus));
        }
        if (!Objects.equals(application.getStatus(), HrmAuditStatusEnum.APPROVE.getStatus())) {
            throw new ServiceException(RESIGNATION_HANDOVER_NOT_APPROVED.getCode(),
                    String.format("离职申请[%s]当前审批状态为[%s],审批通过后才能交接;请先完成审批再操作",
                            application.getNo(), HrmAuditStatusEnum.getNameByStatus(application.getStatus())));
        }
        Integer currentStatus = application.getHandoverStatus() != null
                ? application.getHandoverStatus() : HANDOVER_STATUS_NOT_STARTED;
        if (Objects.equals(currentStatus, HANDOVER_STATUS_COMPLETED)
                && !Objects.equals(targetStatus, HANDOVER_STATUS_COMPLETED)) {
            throw new ServiceException(RESIGNATION_HANDOVER_COMPLETED_CANNOT_REVERT.getCode(),
                    String.format("当前交接状态为[已完成],不能修改为[%s];如需重新交接,请删除该离职申请后重新提交",
                            getHandoverStatusName(targetStatus)));
        }
        // å®¡æ‰¹é€šè¿‡åŽå…è®¸ä¸€æ­¥å®Œæˆäº¤æŽ¥ï¼šæœªå¼€å§‹æˆ–进行中均可直接置为已完成,避免多一次无意义的点击
        if (Objects.equals(targetStatus, HANDOVER_STATUS_COMPLETED)) {
            completeHandover(application);
            return;
        }
        if (Objects.equals(targetStatus, HANDOVER_STATUS_NOT_STARTED)
                && !Objects.equals(currentStatus, HANDOVER_STATUS_NOT_STARTED)
                && !Objects.equals(currentStatus, HANDOVER_STATUS_IN_PROGRESS)) {
            throw new ServiceException(RESIGNATION_HANDOVER_COMPLETED_CANNOT_REVERT.getCode(),
                    String.format("当前交接状态为[%s],不能回退为[未开始];如需重新交接,请删除该离职申请后重新提交",
                            getHandoverStatusName(currentStatus)));
        }
        // è¿›è¡Œä¸­ï¼šå¿…须已指定交接员工;未开始:允许在未指定交接人时回退
        if (Objects.equals(targetStatus, HANDOVER_STATUS_IN_PROGRESS)) {
            resolveStoredHandoverTarget(application);
        }
        resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                .id(application.getId())
                .handoverStatus(targetStatus)
                .handoverDataStatus(targetStatus)
                .build());
    }
    /**
     * å®Œæˆå·¥ä½œäº¤æŽ¥ï¼šå†™å…¥ å‘˜å·¥ID + ç”¨æˆ·ID äº¤æŽ¥æ˜ å°„,交接人通过 expandUserIds èŽ·å¾—ç¦»èŒäººçš„ä¸šåŠ¡æ•°æ®
     */
    private void completeHandover(HrmResignationApplicationDO application) {
        resolveStoredHandoverTarget(application);
        // ç”³è¯·äººæœªå»ºå‘˜å·¥æ¡£æ¡ˆæ—¶ï¼ˆå¦‚管理员账号),仍建立用户级交接映射,保证其业务数据能转交;员工侧 ID ä¸ºç©º
        HrmEmployeeDO fromEmployee = employeeService.getEmployeeByUserId(application.getUserId());
        if (fromEmployee == null) {
            log.warn("[completeHandover][离职申请({}) ç”³è¯·äºº(用户ID={}) æœªå…³è”员工档案,仅建立用户级交接映射]",
                    application.getNo(), application.getUserId());
        }
        handoverService.createHandover(fromEmployee != null ? fromEmployee.getId() : null,
                application.getUserId(),
                application.getHandoverEmployeeId(), application.getHandoverUserId(), application.getId());
        resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                .id(application.getId())
                .handoverStatus(HANDOVER_STATUS_COMPLETED)
                .handoverDataStatus(HANDOVER_STATUS_COMPLETED)
                .build());
    }
    /**
     * æ ¡éªŒå¹¶å›žå¡«å·²ä¿å­˜çš„交接人(兼容历史数据只存了 handoverUserId çš„æƒ…况)
     */
    private void resolveStoredHandoverTarget(HrmResignationApplicationDO application) {
        if (application.getHandoverEmployeeId() == null && application.getHandoverUserId() == null) {
            throw new ServiceException(RESIGNATION_HANDOVER_TARGET_REQUIRED.getCode(),
                    String.format("离职申请[%s]未指定交接员工,无法开始或完成交接;请先编辑申请并选择交接人",
                            application.getNo()));
        }
        resolveHandoverTarget(application, application.getUserId());
        resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                .id(application.getId())
                .handoverEmployeeId(application.getHandoverEmployeeId())
                .handoverUserId(application.getHandoverUserId())
                .build());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelResignationApplication(Long id, Long userId) {
        HrmResignationApplicationDO application = validateResignationApplicationExists(id);
        Integer status = application.getStatus();
        if (Objects.equals(status, HrmAuditStatusEnum.DRAFT.getStatus())) {
            resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                    .id(id)
                    .status(HrmAuditStatusEnum.CANCEL.getStatus())
                    .build());
            return;
        }
        if (!Objects.equals(status, HrmAuditStatusEnum.PROCESS.getStatus())) {
            throw new ServiceException(RESIGNATION_APPLICATION_CANCEL_FAIL_STATUS.getCode(),
                    String.format("离职申请[%s]当前审批状态为[%s],仅草稿或审批中状态可以撤销",
                            application.getNo(), HrmAuditStatusEnum.getNameByStatus(status)));
        }
        if (StrUtil.isNotBlank(application.getProcessInstanceId())) {
            BpmProcessInstanceCancelReqVO cancelReqVO = new BpmProcessInstanceCancelReqVO();
            cancelReqVO.setId(application.getProcessInstanceId());
            cancelReqVO.setReason("申请人撤销离职申请:" + application.getNo());
            Long startUserId = userId != null ? userId : application.getUserId();
            bpmProcessInstanceService.cancelProcessInstanceByStartUser(startUserId, cancelReqVO);
            // æµç¨‹ç›‘听器会同步更新状态;兜底处理未收到回调的场景
            HrmResignationApplicationDO latest = resignationApplicationMapper.selectById(id);
            if (latest != null && Objects.equals(latest.getStatus(), HrmAuditStatusEnum.PROCESS.getStatus())) {
                resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                        .id(id)
                        .status(HrmAuditStatusEnum.CANCEL.getStatus())
                        .build());
            }
            return;
        }
        // æ— æµç¨‹å®žä¾‹ï¼ˆåŽ†å²å…å®¡æ‰¹æ•°æ®ï¼‰ç›´æŽ¥æ’¤é”€
        resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                .id(id)
                .status(HrmAuditStatusEnum.CANCEL.getStatus())
                .build());
    }
    @Override
    public int autoLeaveResignationApplication() {
        LocalDate today = LocalDate.now();
        List<HrmResignationApplicationDO> applications = resignationApplicationMapper
                .selectListByStatusAndExpectedDateLe(HrmAuditStatusEnum.APPROVE.getStatus(), today);
        int count = 0;
        for (HrmResignationApplicationDO application : applications) {
            if (application.getUserId() == null) {
                log.warn("[autoLeaveResignationApplication][申请({}) ç¼ºå°‘申请人用户ID,跳过]", application.getId());
                continue;
            }
            // 1. åœç”¨ç³»ç»Ÿç™»å½•账号
            AdminUserRespDTO user = adminUserApi.getUser(application.getUserId());
            if (user == null) {
                log.warn("[autoLeaveResignationApplication][申请({}) ç”³è¯·äºº(用户ID={}) ä¸å­˜åœ¨ï¼Œè·³è¿‡è´¦å·åœç”¨]",
                        application.getId(), application.getUserId());
            } else if (!Objects.equals(user.getStatus(), CommonStatusEnum.DISABLE.getStatus())) {
                adminUserApi.updateUserStatus(application.getUserId(), CommonStatusEnum.DISABLE.getStatus());
                log.info("[autoLeaveResignationApplication][用户({}) åˆ°è¾¾ç¦»èŒæ—¥æœŸ({}),系统账号已停用]",
                        application.getUserId(), application.getExpectedDate());
            }
            // 2. å‘˜å·¥æ¡£æ¡ˆç½®ä¸ºç¦»èŒ
            HrmEmployeeDO employee = employeeService.getEmployeeByUserId(application.getUserId());
            if (employee == null) {
                log.warn("[autoLeaveResignationApplication][申请({}) ç”³è¯·äºº(用户ID={}) æ— å‘˜å·¥æ¡£æ¡ˆï¼Œè·³è¿‡ï¼›è¯·å…ˆå»ºç«‹å‘˜å·¥æ¡£æ¡ˆ]",
                        application.getId(), application.getUserId());
                continue;
            }
            if (!Objects.equals(employee.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)) {
                employeeService.updateEmployeeStatusToResignedByUserId(application.getUserId(),
                        application.getExpectedDate());
                log.info("[autoLeaveResignationApplication][员工({}) åˆ°è¾¾ç¦»èŒæ—¥æœŸ({}),状态已更新为离职]",
                        employee.getId(), application.getExpectedDate());
            }
            resignationApplicationMapper.updateById(HrmResignationApplicationDO.builder()
                    .id(application.getId())
                    .actualDate(application.getExpectedDate())
                    .build());
            count++;
        }
        return count;
    }
    /**
     * è§£æžäº¤æŽ¥å‘˜å·¥ä¸Žç³»ç»Ÿç”¨æˆ·ï¼Œä¿è¯ handoverEmployeeId / handoverUserId æˆå¯¹ä¸”有效
     * <p>
     * å…¼å®¹å‰ç«¯æŠŠâ€œäº¤æŽ¥äººâ€ä¸‹æ‹‰çš„员工ID写入 handoverUserId çš„历史提交方式:
     * å…ˆæŒ‰ handoverEmployeeId è§£æžï¼›æ²¡æœ‰æ—¶æŠŠ handoverUserId å½“员工ID尝试;再按用户ID解析。
     */
    private void resolveHandoverTarget(HrmResignationApplicationDO application, Long applyUserId) {
        Long employeeId = application.getHandoverEmployeeId();
        Long userId = application.getHandoverUserId();
        if (employeeId == null && userId == null) {
            return;
        }
        HrmEmployeeDO employee;
        if (employeeId != null) {
            employee = employeeMapper.selectById(employeeId);
            if (employee == null) {
                throw new ServiceException(RESIGNATION_HANDOVER_EMPLOYEE_NOT_EXISTS.getCode(),
                        String.format("交接员工(ID=%s)不存在,请重新选择交接员工", employeeId));
            }
        } else {
            // åŽ†å²å‰ç«¯ï¼šhandoverUserId å®žé™…传的是员工ID
            employee = employeeMapper.selectById(userId);
            if (employee == null) {
                AdminUserRespDTO user = adminUserApi.getUser(userId);
                if (user == null) {
                    throw new ServiceException(RESIGNATION_HANDOVER_EMPLOYEE_NOT_EXISTS.getCode(),
                            String.format("交接人(ID=%s)在员工档案和系统用户中均不存在,请重新选择交接员工", userId));
                }
                employee = employeeMapper.selectByUserId(userId);
                if (employee == null) {
                    throw new ServiceException(RESIGNATION_HANDOVER_EMPLOYEE_NO_USER.getCode(),
                            String.format("交接人[%s](用户ID=%s)未关联员工档案,无法确定交接员工;请先在员工管理中为该用户建立员工档案",
                                    user.getNickname(), userId));
                }
            }
            employeeId = employee.getId();
        }
        if (employee.getUserId() == null) {
            throw new ServiceException(RESIGNATION_HANDOVER_EMPLOYEE_NO_USER.getCode(),
                    String.format("交接员工[%s](员工ID=%s)未关联系统用户,无法转移其用户数据;请先在员工管理中关联系统用户",
                            employee.getName(), employee.getId()));
        }
        if (applyUserId != null && Objects.equals(employee.getUserId(), applyUserId)) {
            throw new ServiceException(RESIGNATION_HANDOVER_SELF.getCode(),
                    String.format("交接人[%s](员工ID=%s)与申请人是同一人,请选择其他员工",
                            employee.getName(), employee.getId()));
        }
        if (Objects.equals(employee.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)) {
            throw new ServiceException(RESIGNATION_HANDOVER_EMPLOYEE_RESIGNED.getCode(),
                    String.format("交接员工[%s](员工ID=%s)当前已是离职状态,请重新选择在职员工",
                            employee.getName(), employee.getId()));
        }
        application.setHandoverEmployeeId(employeeId);
        application.setHandoverUserId(employee.getUserId());
    }
    private String getEmployeeDisplayName(Long employeeId) {
        if (employeeId == null) {
            return "未选择";
        }
        HrmEmployeeDO employee = employeeMapper.selectById(employeeId);
        return employee != null ? String.format("%s(ID=%s)", employee.getName(), employeeId)
                : String.format("ID=%s", employeeId);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java
@@ -8,6 +8,7 @@
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSimpleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeStatisticsRespVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import jakarta.validation.Valid;
@@ -115,4 +116,20 @@
    HrmEmployeeImportRespVO importEmployeeList(List<HrmEmployeeImportExcelVO> importEmployees,
                                               HrmEmployeeImportReqVO importReqVO);
    /**
     * å°†æŒ‡å®šç³»ç»Ÿç”¨æˆ·å¯¹åº”的员工状态更新为离职,并记录离职日期
     *
     * @param userId    ç³»ç»Ÿç”¨æˆ·ç¼–号
     * @param leaveDate ç¦»èŒæ—¥æœŸ
     * @return æ˜¯å¦æ›´æ–°åˆ°å‘˜å·¥æ¡£æ¡ˆ
     */
    boolean updateEmployeeStatusToResignedByUserId(Long userId, java.time.LocalDate leaveDate);
    /**
     * èŽ·å¾—å‘˜å·¥äººåŠ›ç»Ÿè®¡ï¼ˆå·¥é¾„ã€å¹´é¾„ã€å­¦åŽ†ã€æ€§åˆ«ã€éƒ¨é—¨å·¥é¾„ç­‰ï¼‰
     *
     * @return äººåŠ›ç»Ÿè®¡æ•°æ®
     */
    HrmEmployeeStatisticsRespVO getTenureStatistics();
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -16,11 +16,14 @@
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSimpleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeStatisticsRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeWorkHistorySaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeContractDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeEducationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeEmergencyContactDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeWorkHistoryDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeContractMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEducationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEmergencyContactMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
@@ -42,9 +45,14 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -67,10 +75,15 @@
@Validated
public class HrmEmployeeServiceImpl implements HrmEmployeeService {
    /** å‘˜å·¥çŠ¶æ€ï¼šç¦»èŒ */
    private static final Integer EMPLOYEE_STATUS_RESIGNED = 3;
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private HrmEmployeeEducationMapper educationMapper;
    @Resource
    private HrmEmployeeContractMapper contractMapper;
    @Resource
    private HrmEmployeeWorkHistoryMapper workHistoryMapper;
    @Resource
@@ -374,6 +387,14 @@
    }
    @Override
    public boolean updateEmployeeStatusToResignedByUserId(Long userId, LocalDate leaveDate) {
        if (userId == null) {
            return false;
        }
        return employeeMapper.updateEmployeeStatusByUserId(userId, EMPLOYEE_STATUS_RESIGNED, leaveDate) > 0;
    }
    @Override
    public HrmEmployeeDO getEmployeeByName(String name) {
        return employeeMapper.selectByName(name);
    }
@@ -526,4 +547,334 @@
        return roleIds;
    }
    // ==================== äººåŠ›ç»Ÿè®¡ ====================
    /** å‘˜å·¥çŠ¶æ€ï¼šåœ¨èŒ */
    private static final Integer EMPLOYEE_STATUS_ACTIVE = 1;
    /** å‘˜å·¥çŠ¶æ€ï¼šè¯•ç”¨ */
    private static final Integer EMPLOYEE_STATUS_PROBATION = 2;
    /** å·¥é¾„区间名称,与下限(含)一一对应,最后一档无上限 */
    private static final String[] TENURE_BUCKET_NAMES = {"1年以内", "1-3å¹´", "3-5å¹´", "5-10å¹´", "10年以上"};
    private static final double[] TENURE_BUCKET_MIN = {0, 1, 3, 5, 10};
    /** å¹´é¾„区间名称,与下限(含)一一对应,最后一档无上限 */
    private static final String[] AGE_BUCKET_NAMES = {"25岁以下", "25-30岁", "30-35岁", "35-40岁", "40-50岁", "50岁以上"};
    private static final int[] AGE_BUCKET_MIN = {0, 25, 30, 35, 40, 50};
    @Override
    public HrmEmployeeStatisticsRespVO getTenureStatistics() {
        List<HrmEmployeeDO> allEmployees = employeeMapper.selectList();
        LocalDate today = LocalDate.now();
        int currentYear = today.getYear();
        List<HrmEmployeeDO> activeEmployees = allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_ACTIVE)
                        || Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_PROBATION))
                .collect(Collectors.toList());
        HrmEmployeeStatisticsRespVO respVO = new HrmEmployeeStatisticsRespVO();
        respVO.setTotalCount(activeEmployees.size());
        respVO.setActiveCount((int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_ACTIVE)).count());
        respVO.setProbationCount((int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_PROBATION)).count());
        respVO.setResignedCount((int) allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)).count());
        // æœ¬å¹´å…¥èŒï¼šåœ¨å²—员工中入职日期落在今年
        respVO.setThisYearHireCount((int) activeEmployees.stream()
                .filter(e -> e.getHireDate() != null && e.getHireDate().getYear() == currentYear).count());
        // æœ¬å¹´ç¦»èŒï¼šç¦»èŒå‘˜å·¥ä¸­ç¦»èŒæ—¥æœŸè½åœ¨ä»Šå¹´
        respVO.setThisYearResignCount((int) allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED))
                .filter(e -> e.getLeaveDate() != null && e.getLeaveDate().getYear() == currentYear).count());
        // 1. å¹³å‡å·¥é¾„、平均年龄
        List<Double> tenureList = activeEmployees.stream()
                .map(e -> calculateTenureYears(e, today))
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        respVO.setAverageTenureYears(averageOf(tenureList));
        List<Integer> ageList = activeEmployees.stream()
                .map(e -> resolveAge(e, today))
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        respVO.setAverageAge(averageOfInt(ageList));
        // 2. å·¥é¾„分布
        int[] tenureCounts = new int[TENURE_BUCKET_NAMES.length];
        for (Double tenure : tenureList) {
            tenureCounts[resolveTenureBucket(tenure)]++;
        }
        List<HrmEmployeeStatisticsRespVO.ItemVO> tenureDistribution = new ArrayList<>();
        for (int i = 0; i < TENURE_BUCKET_NAMES.length; i++) {
            tenureDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO(TENURE_BUCKET_NAMES[i], tenureCounts[i]));
        }
        respVO.setTenureDistribution(tenureDistribution);
        // 3. å¹´é¾„分布
        int[] ageCounts = new int[AGE_BUCKET_NAMES.length];
        for (Integer age : ageList) {
            ageCounts[resolveAgeBucket(age)]++;
        }
        List<HrmEmployeeStatisticsRespVO.ItemVO> ageDistribution = new ArrayList<>();
        for (int i = 0; i < AGE_BUCKET_NAMES.length; i++) {
            ageDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO(AGE_BUCKET_NAMES[i], ageCounts[i]));
        }
        respVO.setAgeDistribution(ageDistribution);
        // 4. å­¦åŽ†åˆ†å¸ƒï¼ˆæœªå¡«å†™çš„å½’å…¥â€œæœªå¡«å†™â€ï¼‰
        Map<String, Integer> educationMap = new LinkedHashMap<>();
        activeEmployees.stream()
                .map(e -> StrUtil.blankToDefault(StrUtil.trim(e.getEducation()), "未填写"))
                .forEach(name -> educationMap.merge(name, 1, Integer::sum));
        respVO.setEducationDistribution(toItemListSorted(educationMap));
        // 5. æ€§åˆ«åˆ†å¸ƒï¼ˆ1 ç”· / 2 å¥³ / å…¶å®ƒ æœªçŸ¥ï¼‰
        Map<String, Integer> genderMap = new LinkedHashMap<>();
        activeEmployees.forEach(e -> {
            String name = Objects.equals(e.getGender(), 1) ? "男" : Objects.equals(e.getGender(), 2) ? "女" : "未知";
            genderMap.merge(name, 1, Integer::sum);
        });
        respVO.setGenderDistribution(toItemListSorted(genderMap));
        // 6. éƒ¨é—¨å·¥é¾„统计
        Set<Long> deptIds = activeEmployees.stream()
                .map(HrmEmployeeDO::getDeptId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, DeptRespDTO> deptMap = CollUtil.isEmpty(deptIds) ? Collections.emptyMap() : deptApi.getDeptMap(deptIds);
        Map<Long, List<Double>> deptTenureMap = new LinkedHashMap<>();
        Map<Long, String> deptNameMap = new LinkedHashMap<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            Double tenure = calculateTenureYears(employee, today);
            if (employee.getDeptId() == null) {
                deptTenureMap.computeIfAbsent(0L, k -> new ArrayList<>()).add(tenure == null ? 0D : tenure);
                deptNameMap.putIfAbsent(0L, "未分配部门");
                continue;
            }
            deptTenureMap.computeIfAbsent(employee.getDeptId(), k -> new ArrayList<>())
                    .add(tenure == null ? 0D : tenure);
            DeptRespDTO dept = deptMap.get(employee.getDeptId());
            deptNameMap.put(employee.getDeptId(), dept != null ? dept.getName() : "部门ID=" + employee.getDeptId());
        }
        List<HrmEmployeeStatisticsRespVO.DeptTenureVO> deptTenureList = new ArrayList<>();
        deptTenureMap.forEach((deptId, tenures) -> {
            HrmEmployeeStatisticsRespVO.DeptTenureVO deptVO = new HrmEmployeeStatisticsRespVO.DeptTenureVO();
            deptVO.setDeptId(deptId == 0L ? null : deptId);
            deptVO.setDeptName(deptNameMap.get(deptId));
            deptVO.setCount(tenures.size());
            deptVO.setAverageTenureYears(averageOf(tenures));
            deptTenureList.add(deptVO);
        });
        deptTenureList.sort(Comparator.comparing(HrmEmployeeStatisticsRespVO.DeptTenureVO::getAverageTenureYears,
                Comparator.nullsLast(Comparator.reverseOrder())));
        respVO.setDeptTenureList(deptTenureList);
        // 6.1 éƒ¨é—¨äººæ•°åˆ†å¸ƒï¼ˆä¸Žéƒ¨é—¨å·¥é¾„同一份分组,直接复用)
        Map<String, Integer> deptCountMap = new LinkedHashMap<>();
        deptTenureMap.forEach((deptId, tenures) -> deptCountMap.put(deptNameMap.get(deptId), tenures.size()));
        respVO.setDeptDistribution(toItemListSorted(deptCountMap));
        // 7. å·¥é¾„排行(前 10 åï¼‰
        respVO.setTopTenureEmployees(activeEmployees.stream()
                .filter(e -> e.getHireDate() != null)
                .sorted(Comparator.comparing(HrmEmployeeDO::getHireDate))
                .limit(10)
                .map(e -> {
                    HrmEmployeeStatisticsRespVO.TenureEmployeeVO employeeVO = new HrmEmployeeStatisticsRespVO.TenureEmployeeVO();
                    employeeVO.setId(e.getId());
                    employeeVO.setName(e.getName());
                    DeptRespDTO dept = e.getDeptId() == null ? null : deptMap.get(e.getDeptId());
                    employeeVO.setDeptName(dept != null ? dept.getName() : (e.getDeptId() == null ? "未分配部门" : "部门ID=" + e.getDeptId()));
                    employeeVO.setHireDate(e.getHireDate());
                    employeeVO.setTenureYears(calculateTenureYears(e, today));
                    return employeeVO;
                })
                .collect(Collectors.toList()));
        // 8. äººå‘˜çŠ¶æ€æž„æˆï¼ˆåœ¨èŒ / è¯•用 / ç¦»èŒï¼Œå…¶ä½™çŠ¶æ€å½’å…¥â€œå…¶å®ƒâ€ï¼‰
        int activeStatusCount = (int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_ACTIVE)).count();
        int probationStatusCount = (int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_PROBATION)).count();
        int resignedStatusCount = (int) allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)).count();
        List<HrmEmployeeStatisticsRespVO.ItemVO> statusDistribution = new ArrayList<>();
        statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("在职", activeStatusCount));
        statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("试用", probationStatusCount));
        statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("离职", resignedStatusCount));
        int otherStatusCount = allEmployees.size() - activeStatusCount - probationStatusCount - resignedStatusCount;
        if (otherStatusCount > 0) {
            statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("其它", otherStatusCount));
        }
        respVO.setStatusDistribution(statusDistribution);
        // 9. å²—位分布(postIds ä¸º JSON æ•°ç»„,可一人多岗;无岗位归入“未分配岗位”)
        Set<Long> postIds = new HashSet<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            if (StrUtil.isNotBlank(employee.getPostIds())) {
                postIds.addAll(JSONUtil.toList(employee.getPostIds(), Long.class));
            }
        }
        Map<Long, PostRespDTO> postMap = CollUtil.isEmpty(postIds) ? Collections.emptyMap() : postApi.getPostMap(postIds);
        Map<String, Integer> postCountMap = new LinkedHashMap<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            List<Long> employeePostIds = StrUtil.isNotBlank(employee.getPostIds())
                    ? JSONUtil.toList(employee.getPostIds(), Long.class) : Collections.emptyList();
            if (CollUtil.isEmpty(employeePostIds)) {
                postCountMap.merge("未分配岗位", 1, Integer::sum);
                continue;
            }
            for (Long postId : employeePostIds) {
                PostRespDTO post = postMap.get(postId);
                postCountMap.merge(post != null ? post.getName() : "岗位ID=" + postId, 1, Integer::sum);
            }
        }
        respVO.setPostDistribution(toItemListSorted(postCountMap));
        // 10. å©šå§»çŠ¶å†µã€æ”¿æ²»é¢è²Œåˆ†å¸ƒï¼ˆæ¡£æ¡ˆè‡ªç”±æ–‡æœ¬ï¼Œç©ºå€¼å½’å…¥â€œæœªå¡«å†™â€ï¼‰
        Map<String, Integer> marriageMap = new LinkedHashMap<>();
        Map<String, Integer> politicalMap = new LinkedHashMap<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            marriageMap.merge(StrUtil.blankToDefault(StrUtil.trim(employee.getMarriageStatus()), "未填写"), 1, Integer::sum);
            politicalMap.merge(StrUtil.blankToDefault(StrUtil.trim(employee.getPoliticalStatus()), "未填写"), 1, Integer::sum);
        }
        respVO.setMarriageDistribution(toItemListSorted(marriageMap));
        respVO.setPoliticalDistribution(toItemListSorted(politicalMap));
        // 11. è¿‘ 12 ä¸ªæœˆå…¥èŒ / ç¦»èŒè¶‹åŠ¿ï¼ˆå«æœ¬æœˆï¼‰
        Map<YearMonth, Integer> hireMonthMap = new LinkedHashMap<>();
        Map<YearMonth, Integer> resignMonthMap = new LinkedHashMap<>();
        allEmployees.forEach(e -> {
            if (e.getHireDate() != null) {
                hireMonthMap.merge(YearMonth.from(e.getHireDate()), 1, Integer::sum);
            }
            if (e.getLeaveDate() != null) {
                resignMonthMap.merge(YearMonth.from(e.getLeaveDate()), 1, Integer::sum);
            }
        });
        List<HrmEmployeeStatisticsRespVO.TrendVO> trendList = new ArrayList<>();
        YearMonth startMonth = YearMonth.from(today).minusMonths(11);
        for (int i = 0; i < 12; i++) {
            YearMonth month = startMonth.plusMonths(i);
            HrmEmployeeStatisticsRespVO.TrendVO trendVO = new HrmEmployeeStatisticsRespVO.TrendVO();
            trendVO.setMonth(month.toString());
            trendVO.setHireCount(hireMonthMap.getOrDefault(month, 0));
            trendVO.setResignCount(resignMonthMap.getOrDefault(month, 0));
            trendList.add(trendVO);
        }
        respVO.setHireResignTrend(trendList);
        // 12. åˆåŒåˆ°æœŸé¢„警(只看在岗员工的当前生效合同)
        Set<Long> activeEmployeeIds = activeEmployees.stream()
                .map(HrmEmployeeDO::getId)
                .collect(Collectors.toSet());
        Map<Long, HrmEmployeeContractDO> currentContractMap = contractMapper.selectList().stream()
                .filter(c -> Boolean.TRUE.equals(c.getIsCurrent()))
                .filter(c -> c.getTerminateStatus() == null || c.getTerminateStatus() == 0)
                .filter(c -> activeEmployeeIds.contains(c.getEmployeeId()))
                .collect(Collectors.toMap(HrmEmployeeContractDO::getEmployeeId, c -> c, (a, b) -> a));
        int expiredCount = 0;
        int expireIn30Count = 0;
        int expireIn90Count = 0;
        int expireOver90Count = 0;
        int unlimitedCount = 0;
        for (HrmEmployeeContractDO contract : currentContractMap.values()) {
            if (contract.getEndDate() == null) {
                unlimitedCount++;
                continue;
            }
            long days = ChronoUnit.DAYS.between(today, contract.getEndDate());
            if (days < 0) {
                expiredCount++;
            } else if (days <= 30) {
                expireIn30Count++;
            } else if (days <= 90) {
                expireIn90Count++;
            } else {
                expireOver90Count++;
            }
        }
        List<HrmEmployeeStatisticsRespVO.ItemVO> contractWarning = new ArrayList<>();
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("已到期", expiredCount));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("30天内到期", expireIn30Count));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("31-90天到期", expireIn90Count));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("90天以上", expireOver90Count));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("无固定期限", unlimitedCount));
        respVO.setContractWarning(contractWarning);
        respVO.setNoContractCount(Math.max(0, activeEmployeeIds.size() - currentContractMap.size()));
        return respVO;
    }
    /**
     * è®¡ç®—工龄(年,保留 1 ä½å°æ•°ï¼‰ï¼›ç¦»èŒå‘˜å·¥æŒ‰ç¦»èŒæ—¥æœŸè®¡ç®—,无入职日期返回 null
     */
    private Double calculateTenureYears(HrmEmployeeDO employee, LocalDate today) {
        if (employee.getHireDate() == null) {
            return null;
        }
        LocalDate endDate = employee.getLeaveDate() != null ? employee.getLeaveDate() : today;
        if (endDate.isBefore(employee.getHireDate())) {
            return 0D;
        }
        double years = ChronoUnit.DAYS.between(employee.getHireDate(), endDate) / 365.25D;
        return Math.round(years * 10D) / 10D;
    }
    /**
     * è§£æžå¹´é¾„:优先取档案年龄,缺失时用出生日期推算
     */
    private Integer resolveAge(HrmEmployeeDO employee, LocalDate today) {
        if (employee.getAge() != null && employee.getAge() > 0) {
            return employee.getAge();
        }
        if (employee.getBirthday() == null) {
            return null;
        }
        return (int) ChronoUnit.YEARS.between(employee.getBirthday(), today);
    }
    private int resolveTenureBucket(double tenure) {
        for (int i = TENURE_BUCKET_MIN.length - 1; i >= 0; i--) {
            if (tenure >= TENURE_BUCKET_MIN[i]) {
                return i;
            }
        }
        return 0;
    }
    private int resolveAgeBucket(int age) {
        for (int i = AGE_BUCKET_MIN.length - 1; i >= 0; i--) {
            if (age >= AGE_BUCKET_MIN[i]) {
                return i;
            }
        }
        return 0;
    }
    private List<HrmEmployeeStatisticsRespVO.ItemVO> toItemListSorted(Map<String, Integer> countMap) {
        return countMap.entrySet().stream()
                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
                .map(entry -> new HrmEmployeeStatisticsRespVO.ItemVO(entry.getKey(), entry.getValue()))
                .collect(Collectors.toList());
    }
    private Double averageOf(List<Double> values) {
        if (CollUtil.isEmpty(values)) {
            return 0D;
        }
        double sum = values.stream().mapToDouble(Double::doubleValue).sum();
        return Math.round(sum / values.size() * 10D) / 10D;
    }
    private Double averageOfInt(List<Integer> values) {
        if (CollUtil.isEmpty(values)) {
            return 0D;
        }
        double sum = values.stream().mapToInt(Integer::intValue).sum();
        return Math.round(sum / values.size() * 10D) / 10D;
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/handover/HrmUserHandoverService.java
@@ -12,11 +12,14 @@
    /**
     * åˆ›å»ºäº¤æŽ¥æ˜ å°„
     *
     * @param fromUserId    ç¦»èŒç”¨æˆ·ID
     * @param toUserId      äº¤æŽ¥ç›®æ ‡ç”¨æˆ·ID
     * @param resignationId ç¦»èŒç”³è¯·ID
     * @param fromEmployeeId ç¦»èŒå‘˜å·¥ID
     * @param fromUserId     ç¦»èŒç”¨æˆ·ID
     * @param toEmployeeId   äº¤æŽ¥ç›®æ ‡å‘˜å·¥ID
     * @param toUserId       äº¤æŽ¥ç›®æ ‡ç”¨æˆ·ID
     * @param resignationId  ç¦»èŒç”³è¯·ID
     */
    void createHandover(Long fromUserId, Long toUserId, Long resignationId);
    void createHandover(Long fromEmployeeId, Long fromUserId,
                        Long toEmployeeId, Long toUserId, Long resignationId);
    /**
     * æ‰©å±•用户ID集合,将交接源用户ID加入
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/handover/HrmUserHandoverServiceImpl.java
@@ -39,7 +39,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void createHandover(Long fromUserId, Long toUserId, Long resignationId) {
    public void createHandover(Long fromEmployeeId, Long fromUserId,
                               Long toEmployeeId, Long toUserId, Long resignationId) {
        // æ ¡éªŒæºç”¨æˆ·å­˜åœ¨
        AdminUserRespDTO fromUser = adminUserApi.getUser(fromUserId);
        if (fromUser == null) {
@@ -56,14 +57,17 @@
            throw exception(USER_HANDOVER_ALREADY_EXISTS);
        }
        HrmUserHandoverDO record = HrmUserHandoverDO.builder()
                .fromEmployeeId(fromEmployeeId)
                .fromUserId(fromUserId)
                .toEmployeeId(toEmployeeId)
                .toUserId(toUserId)
                .resignationId(resignationId)
                .handoverStatus(HrmHandoverStatusEnum.COMPLETED.getStatus())
                .handoverTime(LocalDateTime.now())
                .build();
        userHandoverMapper.insert(record);
        log.info("[createHandover] åˆ›å»ºäº¤æŽ¥æ˜ å°„: fromUserId={}, toUserId={}, resignationId={}", fromUserId, toUserId, resignationId);
        log.info("[createHandover] åˆ›å»ºäº¤æŽ¥æ˜ å°„: fromEmployeeId={}, fromUserId={}, toEmployeeId={}, toUserId={}, resignationId={}",
                fromEmployeeId, fromUserId, toEmployeeId, toUserId, resignationId);
    }
    @Override
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/MesWmItemReceiptApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.mes.api.itemreceipt;
import cn.iocoder.yudao.module.mes.api.itemreceipt.dto.MesWmItemReceiptRespDTO;
import java.util.List;
/**
 * MES é‡‡è´­å…¥åº“单 API æŽ¥å£
 *
 * ä¾› ERP æ¥ç¥¨ç»‘定入库单使用
 */
public interface MesWmItemReceiptApi {
    /**
     * æ ¹æ®é‡‡è´­è®¢å•编号查询已完成入库的入库单列表
     *
     * @param purchaseOrderId é‡‡è´­è®¢å•编号
     * @return å…¥åº“单列表(含明细)
     */
    List<MesWmItemReceiptRespDTO> getItemReceiptListByPurchaseOrderId(Long purchaseOrderId);
    /**
     * èŽ·å¾—å…¥åº“å•
     *
     * @param id å…¥åº“单编号
     * @return å…¥åº“单(含明细),不存在时返回 null
     */
    MesWmItemReceiptRespDTO getItemReceipt(Long id);
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/dto/MesWmItemReceiptLineRespDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.mes.api.itemreceipt.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
 * MES é‡‡è´­å…¥åº“单行 Response DTO
 */
@Data
public class MesWmItemReceiptLineRespDTO {
    /**
     * å…¥åº“单行编号
     */
    private Long id;
    /**
     * é‡‡è´­è®¢å•明细编号
     */
    private Long purchaseOrderItemId;
    /**
     * ç‰©æ–™ç¼–号
     */
    private Long itemId;
    /**
     * å…¥åº“数量
     */
    private BigDecimal receivedQuantity;
    /**
     * å•价(入库时按采购订单行单价快照)
     */
    private BigDecimal price;
    /**
     * é‡‘额
     */
    private BigDecimal totalPrice;
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/dto/MesWmItemReceiptRespDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.mes.api.itemreceipt.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
 * MES é‡‡è´­å…¥åº“单 Response DTO
 */
@Data
public class MesWmItemReceiptRespDTO {
    private Long id;
    /**
     * å…¥åº“单号
     */
    private String code;
    /**
     * å…¥åº“单名称
     */
    private String name;
    /**
     * åˆ°è´§é€šçŸ¥å•编号
     */
    private Long noticeId;
    /**
     * é‡‡è´­è®¢å•编号
     */
    private Long purchaseOrderId;
    /**
     * é‡‡è´­è®¢å•号
     */
    private String purchaseOrderCode;
    /**
     * ä¾›åº”商编号
     */
    private Long vendorId;
    /**
     * å…¥åº“日期
     */
    private LocalDateTime receiptDate;
    /**
     * çŠ¶æ€
     */
    private Integer status;
    /**
     * å…¥åº“总金额
     */
    private BigDecimal totalPrice;
    /**
     * å…¥åº“明细
     */
    private List<MesWmItemReceiptLineRespDTO> lines;
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/MesWmProductSalesApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.mes.api.productsales;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO;
import java.util.List;
/**
 * MES é”€å”®å‡ºåº“单 API æŽ¥å£
 *
 * ä¾› CRM å¼€ç¥¨ç»‘定出库单、财务对账使用
 */
public interface MesWmProductSalesApi {
    /**
     * æ ¹æ®é”€å”®è®¢å•编号查询出库单列表
     *
     * @param saleOrderId é”€å”®è®¢å•编号
     * @return å‡ºåº“单列表(含明细)
     */
    List<MesWmProductSalesRespDTO> getProductSalesListBySaleOrderId(Long saleOrderId);
    /**
     * æ ¹æ®å®¢æˆ·ç¼–号查询出库单列表
     *
     * @param clientId å®¢æˆ·ç¼–号
     * @return å‡ºåº“单列表(含明细)
     */
    List<MesWmProductSalesRespDTO> getProductSalesListByClientId(Long clientId);
    /**
     * èŽ·å¾—å‡ºåº“å•
     *
     * @param id å‡ºåº“单编号
     * @return å‡ºåº“单(含明细),不存在时返回 null
     */
    MesWmProductSalesRespDTO getProductSales(Long id);
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/dto/MesWmProductSalesLineRespDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.mes.api.productsales.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
 * MES é”€å”®å‡ºåº“单行 Response DTO
 */
@Data
public class MesWmProductSalesLineRespDTO {
    /**
     * å‡ºåº“单行编号
     */
    private Long id;
    /**
     * é”€å”®è®¢å•明细编号(手工出库时可能为空)
     */
    private Long saleOrderItemId;
    /**
     * ç‰©æ–™ç¼–号
     */
    private Long itemId;
    /**
     * å‡ºåº“数量
     */
    private BigDecimal quantity;
    /**
     * å•价(出库时按销售订单行单价快照)
     */
    private BigDecimal price;
    /**
     * é‡‘额
     */
    private BigDecimal totalPrice;
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/dto/MesWmProductSalesRespDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.mes.api.productsales.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
 * MES é”€å”®å‡ºåº“单 Response DTO
 */
@Data
public class MesWmProductSalesRespDTO {
    private Long id;
    /**
     * å‡ºåº“单号
     */
    private String code;
    /**
     * å®¢æˆ·ç¼–号
     */
    private Long clientId;
    /**
     * é”€å”®è®¢å•编号
     */
    private Long saleOrderId;
    /**
     * é”€å”®è®¢å•号
     */
    private String salesOrderCode;
    /**
     * å‘货通知单编号
     */
    private Long noticeId;
    /**
     * å‡ºåº“日期
     */
    private LocalDateTime salesDate;
    /**
     * çŠ¶æ€
     */
    private Integer status;
    /**
     * å‡ºåº“总金额
     */
    private BigDecimal totalPrice;
    /**
     * å‡ºåº“明细
     */
    private List<MesWmProductSalesLineRespDTO> lines;
}
yudao-module-mes/pom.xml
@@ -73,6 +73,13 @@
            <version>${revision}</version>
        </dependency>
        <!-- å”®åŽå·¥å• API:仓储退货后同步流转记录 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-aftersales-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- ä¸šåŠ¡ç»„ä»¶ -->
        <!-- Web ç›¸å…³ -->
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/itemreceipt/MesWmItemReceiptApiImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
package cn.iocoder.yudao.module.mes.api.itemreceipt;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.api.itemreceipt.dto.MesWmItemReceiptLineRespDTO;
import cn.iocoder.yudao.module.mes.api.itemreceipt.dto.MesWmItemReceiptRespDTO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemreceipt.MesWmItemReceiptLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemreceipt.MesWmItemReceiptMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
 * MES é‡‡è´­å…¥åº“单 API å®žçŽ°ç±»
 */
@Service
public class MesWmItemReceiptApiImpl implements MesWmItemReceiptApi {
    @Resource
    private MesWmItemReceiptMapper itemReceiptMapper;
    @Resource
    private MesWmItemReceiptLineMapper itemReceiptLineMapper;
    @Override
    public List<MesWmItemReceiptRespDTO> getItemReceiptListByPurchaseOrderId(Long purchaseOrderId) {
        if (purchaseOrderId == null) {
            return List.of();
        }
        List<MesWmItemReceiptDO> receipts = itemReceiptMapper.selectListByPurchaseOrderId(purchaseOrderId);
        if (CollUtil.isEmpty(receipts)) {
            return List.of();
        }
        Map<Long, List<MesWmItemReceiptLineDO>> lineMap = itemReceiptLineMapper
                .selectListByReceiptIds(receipts.stream().map(MesWmItemReceiptDO::getId).toList())
                .stream().collect(Collectors.groupingBy(MesWmItemReceiptLineDO::getReceiptId));
        return receipts.stream()
                .map(receipt -> convert(receipt, lineMap.getOrDefault(receipt.getId(), List.of())))
                .toList();
    }
    @Override
    public MesWmItemReceiptRespDTO getItemReceipt(Long id) {
        MesWmItemReceiptDO receipt = itemReceiptMapper.selectById(id);
        if (receipt == null) {
            return null;
        }
        return convert(receipt, itemReceiptLineMapper.selectListByReceiptId(id));
    }
    private MesWmItemReceiptRespDTO convert(MesWmItemReceiptDO receipt, List<MesWmItemReceiptLineDO> lines) {
        MesWmItemReceiptRespDTO resp = BeanUtils.toBean(receipt, MesWmItemReceiptRespDTO.class);
        List<MesWmItemReceiptLineRespDTO> lineResps = BeanUtils.toBean(lines, MesWmItemReceiptLineRespDTO.class);
        resp.setLines(lineResps);
        resp.setTotalPrice(lineResps.stream()
                .map(line -> line.getTotalPrice() != null ? line.getTotalPrice() : calcLineAmount(line))
                .filter(amount -> amount != null)
                .reduce(BigDecimal.ZERO, BigDecimal::add));
        return resp;
    }
    private BigDecimal calcLineAmount(MesWmItemReceiptLineRespDTO line) {
        if (line.getPrice() == null || line.getReceivedQuantity() == null) {
            return null;
        }
        return line.getPrice().multiply(line.getReceivedQuantity());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/productsales/MesWmProductSalesApiImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
package cn.iocoder.yudao.module.mes.api.productsales;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesLineRespDTO;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesLineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
 * MES é”€å”®å‡ºåº“单 API å®žçŽ°ç±»
 */
@Service
public class MesWmProductSalesApiImpl implements MesWmProductSalesApi {
    @Resource
    private MesWmProductSalesMapper productSalesMapper;
    @Resource
    private MesWmProductSalesLineMapper productSalesLineMapper;
    @Override
    public List<MesWmProductSalesRespDTO> getProductSalesListBySaleOrderId(Long saleOrderId) {
        if (saleOrderId == null) {
            return List.of();
        }
        List<MesWmProductSalesDO> salesList = productSalesMapper.selectListBySaleOrderId(saleOrderId);
        return convertList(salesList);
    }
    @Override
    public List<MesWmProductSalesRespDTO> getProductSalesListByClientId(Long clientId) {
        if (clientId == null) {
            return List.of();
        }
        return convertList(productSalesMapper.selectListByClientId(clientId));
    }
    @Override
    public MesWmProductSalesRespDTO getProductSales(Long id) {
        MesWmProductSalesDO sales = productSalesMapper.selectById(id);
        if (sales == null) {
            return null;
        }
        return convert(sales, productSalesLineMapper.selectListBySalesId(id));
    }
    private List<MesWmProductSalesRespDTO> convertList(List<MesWmProductSalesDO> salesList) {
        if (CollUtil.isEmpty(salesList)) {
            return List.of();
        }
        Map<Long, List<MesWmProductSalesLineDO>> lineMap = productSalesLineMapper
                .selectListBySalesIds(salesList.stream().map(MesWmProductSalesDO::getId).toList())
                .stream().collect(Collectors.groupingBy(MesWmProductSalesLineDO::getSalesId));
        return salesList.stream()
                .map(sales -> convert(sales, lineMap.getOrDefault(sales.getId(), List.of())))
                .toList();
    }
    private MesWmProductSalesRespDTO convert(MesWmProductSalesDO sales, List<MesWmProductSalesLineDO> lines) {
        MesWmProductSalesRespDTO resp = BeanUtils.toBean(sales, MesWmProductSalesRespDTO.class);
        List<MesWmProductSalesLineRespDTO> lineResps = BeanUtils.toBean(lines, MesWmProductSalesLineRespDTO.class);
        resp.setLines(lineResps);
        resp.setTotalPrice(lineResps.stream()
                .map(line -> line.getTotalPrice() != null ? line.getTotalPrice() : calcLineAmount(line))
                .filter(amount -> amount != null)
                .reduce(BigDecimal.ZERO, BigDecimal::add));
        return resp;
    }
    private BigDecimal calcLineAmount(MesWmProductSalesLineRespDTO line) {
        if (line.getPrice() == null || line.getQuantity() == null) {
            return null;
        }
        return line.getPrice().multiply(line.getQuantity());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/checkplan/vo/machinery/MesDvCheckPlanMachineryRespVO.java
@@ -18,7 +18,7 @@
    @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long machineryId;
    @Schema(description = "设备编码", example = "EQ001")
    @Schema(description = "设备编号", example = "EQ001")
    private String machineryCode;
    @Schema(description = "设备名称", example = "注塑机A")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/checkrecord/vo/MesDvCheckRecordRespVO.java
@@ -50,8 +50,8 @@
    @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long machineryId;
    @Schema(description = "设备编码", example = "M001")
    @ExcelProperty("设备编码")
    @Schema(description = "设备编号", example = "M001")
    @ExcelProperty("设备编号")
    private String machineryCode;
    @Schema(description = "设备名称", example = "机床A")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryImportExcelVO.java
@@ -18,7 +18,7 @@
@NoArgsConstructor
public class MesDvMachineryImportExcelVO {
    @ExcelProperty("设备编码")
    @ExcelProperty("设备编号")
    @cn.iocoder.yudao.framework.excel.core.handler.ExcelRequired
    private String code;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryImportRespVO.java
@@ -12,13 +12,13 @@
@Builder
public class MesDvMachineryImportRespVO {
    @Schema(description = "创建成功的设备编码数组", requiredMode = Schema.RequiredMode.REQUIRED)
    @Schema(description = "创建成功的设备编号数组", requiredMode = Schema.RequiredMode.REQUIRED)
    private List<String> createCodes;
    @Schema(description = "更新成功的设备编码数组", requiredMode = Schema.RequiredMode.REQUIRED)
    @Schema(description = "更新成功的设备编号数组", requiredMode = Schema.RequiredMode.REQUIRED)
    private List<String> updateCodes;
    @Schema(description = "导入失败的设备集合,key ä¸ºè®¾å¤‡ç¼–码,value ä¸ºå¤±è´¥åŽŸå› ", requiredMode = Schema.RequiredMode.REQUIRED)
    @Schema(description = "导入失败的设备集合,key ä¸ºè®¾å¤‡ç¼–号,value ä¸ºå¤±è´¥åŽŸå› ", requiredMode = Schema.RequiredMode.REQUIRED)
    private Map<String, String> failureCodes;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryPageReqVO.java
@@ -14,7 +14,7 @@
@ToString(callSuper = true)
public class MesDvMachineryPageReqVO extends PageParam {
    @Schema(description = "设备编码", example = "EQ-001")
    @Schema(description = "设备编号", example = "EQ-001")
    private String code;
    @Schema(description = "设备名称", example = "CNC åŠ å·¥ä¸­å¿ƒ")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachineryRespVO.java
@@ -19,8 +19,8 @@
    @ExcelProperty("编号")
    private Long id;
    @Schema(description = "设备编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "EQ-001")
    @ExcelProperty("设备编码")
    @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "EQ-001")
    @ExcelProperty("设备编号")
    private String code;
    @Schema(description = "设备名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "CNC åŠ å·¥ä¸­å¿ƒ")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/machinery/vo/MesDvMachinerySaveReqVO.java
@@ -14,8 +14,8 @@
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "设备编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "EQ-001")
    @NotEmpty(message = "设备编码不能为空")
    @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "EQ-001")
    @NotEmpty(message = "设备编号不能为空")
    private String code;
    @Schema(description = "设备名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "CNC åŠ å·¥ä¸­å¿ƒ")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/maintenrecord/vo/MesDvMaintenRecordRespVO.java
@@ -50,8 +50,8 @@
    @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long machineryId;
    @Schema(description = "设备编码", example = "M001")
    @ExcelProperty("设备编码")
    @Schema(description = "设备编号", example = "M001")
    @ExcelProperty("设备编号")
    private String machineryCode;
    @Schema(description = "设备名称", example = "机床A")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/repair/vo/MesDvRepairRespVO.java
@@ -30,8 +30,8 @@
    @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long machineryId;
    @Schema(description = "设备编码", example = "M001")
    @ExcelProperty("设备编码")
    @Schema(description = "设备编号", example = "M001")
    @ExcelProperty("设备编号")
    private String machineryCode;
    @Schema(description = "设备名称", example = "注塑机")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/md/workstation/vo/machine/MesMdWorkstationMachineRespVO.java
@@ -18,7 +18,7 @@
    @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long machineryId;
    @Schema(description = "设备编码", example = "M001")
    @Schema(description = "设备编号", example = "M001")
    private String machineryCode;
    @Schema(description = "设备名称", example = "CNC数控机床")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/MesWmArrivalNoticeController.java
@@ -145,8 +145,13 @@
        return BeanUtils.toBean(list, MesWmArrivalNoticeRespVO.class, vo -> {
            MapUtils.findAndThen(supplierMap, vo.getVendorId(), supplier ->
                    vo.setVendorName(supplier.getName()));
            MapUtils.findAndThen(purchaseOrderMap, vo.getPurchaseOrderId(), purchaseOrder ->
                    vo.setPurchaseOrderNo(purchaseOrder.getNo()));
            MapUtils.findAndThen(purchaseOrderMap, vo.getPurchaseOrderId(), purchaseOrder -> {
                vo.setPurchaseOrderNo(purchaseOrder.getNo());
                // åŽ†å²å•æ®æœªå¿«ç…§åˆåŒå·æ—¶ï¼Œå›žå¡«é‡‡è´­è®¢å•ä¸Šçš„åˆåŒå·
                if (cn.hutool.core.util.StrUtil.isBlank(vo.getContractNo())) {
                    vo.setContractNo(purchaseOrder.getContractNo());
                }
            });
        });
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/vo/MesWmArrivalNoticePageReqVO.java
@@ -26,6 +26,9 @@
    @Schema(description = "采购订单编号", example = "1")
    private Long purchaseOrderId;
    @Schema(description = "采购合同号", example = "HT20260101")
    private String contractNo;
    @Schema(description = "供应商编号", example = "1")
    private Long vendorId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/vo/MesWmArrivalNoticeRespVO.java
@@ -31,6 +31,10 @@
    @ExcelProperty("采购订单号")
    private String purchaseOrderNo;
    @Schema(description = "采购合同号", example = "HT20260101")
    @ExcelProperty("采购合同号")
    private String contractNo;
    @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long vendorId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/arrivalnotice/vo/MesWmArrivalNoticeSaveReqVO.java
@@ -28,6 +28,9 @@
    @Schema(description = "采购订单编码", example = "PO20260101")
    private String purchaseOrderCode;
    @Schema(description = "采购合同号", example = "HT20260101")
    private String contractNo;
    @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "供应商编号不能为空")
    private Long vendorId;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/dv/machinery/MesDvMachineryDO.java
@@ -30,7 +30,7 @@
    @TableId
    private Long id;
    /**
     * è®¾å¤‡ç¼–码
     * è®¾å¤‡ç¼–号
     */
    private String code;
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/arrivalnotice/MesWmArrivalNoticeDO.java
@@ -47,6 +47,10 @@
     */
    private String purchaseOrderCode;
    /**
     * é‡‡è´­åˆåŒå·ï¼ˆå†—余字段,创建时从采购订单快照;换货到货沿用原退货单合同号)
     */
    private String contractNo;
    /**
     * ä¾›åº”商编号
     *
     * å…³è” ERP ä¾›åº”商 {@link cn.iocoder.yudao.module.erp.api.purchase.dto.ErpSupplierRespDTO#getId()}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/itemreceipt/MesWmItemReceiptLineDO.java
@@ -58,6 +58,16 @@
     * å…¥åº“数量
     */
    private BigDecimal receivedQuantity;
    /**
     * å•价(入库时按采购订单行单价快照)
     */
    private BigDecimal price;
    /**
     * é‡‘额
     */
    private BigDecimal totalPrice;
    /**
     * æ‰¹æ¬¡ç¼–号
     *
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/productsales/MesWmProductSalesLineDO.java
@@ -62,6 +62,14 @@
     */
    private BigDecimal quantity;
    /**
     * å•价(出库时按销售订单行单价快照)
     */
    private BigDecimal price;
    /**
     * é‡‘额
     */
    private BigDecimal totalPrice;
    /**
     * æ‰¹æ¬¡ ID
     *
     * å…³è” {@link MesWmBatchDO#getId()}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/arrivalnotice/MesWmArrivalNoticeMapper.java
@@ -18,6 +18,7 @@
                .likeIfPresent(MesWmArrivalNoticeDO::getCode, reqVO.getCode())
                .likeIfPresent(MesWmArrivalNoticeDO::getName, reqVO.getName())
                .eqIfPresent(MesWmArrivalNoticeDO::getPurchaseOrderId, reqVO.getPurchaseOrderId())
                .likeIfPresent(MesWmArrivalNoticeDO::getContractNo, reqVO.getContractNo())
                .eqIfPresent(MesWmArrivalNoticeDO::getVendorId, reqVO.getVendorId())
                .betweenIfPresent(MesWmArrivalNoticeDO::getArrivalDate, reqVO.getArrivalDate())
                .eqIfPresent(MesWmArrivalNoticeDO::getStatus, reqVO.getStatus())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/itemreceipt/MesWmItemReceiptMapper.java
@@ -40,4 +40,10 @@
        return selectOne(MesWmItemReceiptDO::getNoticeId, noticeId);
    }
    default List<MesWmItemReceiptDO> selectListByPurchaseOrderId(Long purchaseOrderId) {
        return selectList(new LambdaQueryWrapperX<MesWmItemReceiptDO>()
                .eq(MesWmItemReceiptDO::getPurchaseOrderId, purchaseOrderId)
                .orderByDesc(MesWmItemReceiptDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesLineMapper.java
@@ -21,6 +21,11 @@
        return selectList(MesWmProductSalesLineDO::getSalesId, salesId);
    }
    default List<MesWmProductSalesLineDO> selectListBySalesIds(java.util.Collection<Long> salesIds) {
        return selectList(new LambdaQueryWrapperX<MesWmProductSalesLineDO>()
                .in(MesWmProductSalesLineDO::getSalesId, salesIds));
    }
    default void deleteBySalesId(Long salesId) {
        delete(MesWmProductSalesLineDO::getSalesId, salesId);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/wm/productsales/MesWmProductSalesMapper.java
@@ -62,4 +62,10 @@
        return selectList(MesWmProductSalesDO::getClientId, clientId);
    }
    default List<MesWmProductSalesDO> selectListBySaleOrderId(Long saleOrderId) {
        return selectList(new LambdaQueryWrapperX<MesWmProductSalesDO>()
                .eq(MesWmProductSalesDO::getSaleOrderId, saleOrderId)
                .orderByDesc(MesWmProductSalesDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -163,7 +163,7 @@
    // ========== MES è®¾å¤‡ç®¡ç†-设备台账(1-040-301-000) ==========
    ErrorCode DV_MACHINERY_NOT_EXISTS = new ErrorCode(1_040_301_000, "设备不存在");
    ErrorCode DV_MACHINERY_CODE_DUPLICATE = new ErrorCode(1_040_301_001, "设备编码已存在");
    ErrorCode DV_MACHINERY_CODE_DUPLICATE = new ErrorCode(1_040_301_001, "设备编号已存在");
    ErrorCode DV_MACHINERY_IS_DISABLE = new ErrorCode(1_040_301_007, "设备已禁用");
    ErrorCode DV_MACHINERY_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_040_301_002, "导入设备数据不能为空");
    ErrorCode DV_MACHINERY_HAS_CHECK_PLAN = new ErrorCode(1_040_301_003, "设备已关联点检计划,无法删除");
@@ -664,6 +664,7 @@
    ErrorCode WM_RETURN_VENDOR_HANDLE_TYPE_INVALID = new ErrorCode(1_040_711_012, "供应商退货单处理方式不正确:当前为[{}],仅支持 1退款 / 2换货,请修改处理方式后再操作");
    ErrorCode WM_RETURN_VENDOR_REFUND_AMOUNT_REQUIRED = new ErrorCode(1_040_711_013, "无法自动计算退款金额:退货单[{}]未关联采购订单,或退货物料[{}]未匹配到采购订单明细。请在退货单填写退款金额后再完成退货");
    ErrorCode WM_RETURN_VENDOR_FINISHED_REQUIRED = new ErrorCode(1_040_711_014, "供应商退货单[{}]当前状态为[{}],只有已完成的退货单才能执行该操作");
    ErrorCode WM_RETURN_VENDOR_ATTACHMENT_REQUIRED = new ErrorCode(1_040_711_015, "供应商退货单[{}]未上传退货附件,质量部发起退货必须上传质检报告、不合格照片等附件后才能提交审批,请先编辑单据上传附件");
    // ========== MES ä»“库管理-产品收货单(1-040-712-000) ==========
    ErrorCode WM_PRODUCT_RECPT_NOT_EXISTS = new ErrorCode(1_040_712_000, "产品收货单不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/md/autocode/MesMdAutoCodeRuleCodeEnum.java
@@ -40,7 +40,7 @@
    WM_STOCK_TAKING_CODE("WM_STOCK_TAKING_CODE", "盘点任务编码"),
    TRANSFER_CODE("TRANSFER_CODE", "转移调拨单编码"),
    WM_OUTSOURCE_ISSUE_CODE("WM_OUTSOURCE_ISSUE_CODE", "外协发料单编码"),
    DV_MACHINERY_CODE("DV_MACHINERY_CODE", "设备编码"),
    DV_MACHINERY_CODE("DV_MACHINERY_CODE", "设备编号"),
    DV_MACHINERY_TYPE_CODE("DV_MACHINERY_TYPE_CODE", "设备类型编码"),
    DV_SUBJECT_CODE("DV_SUBJECT_CODE", "点检保养项目编码"),
    DV_REPAIR_CODE("DV_REPAIR_CODE", "维修单编码"),
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/dv/machinery/MesDvMachineryServiceImpl.java
@@ -4,6 +4,7 @@
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
@@ -147,7 +148,8 @@
            return;
        }
        if (ObjUtil.notEqual(machinery.getId(), id)) {
            throw exception(DV_MACHINERY_CODE_DUPLICATE);
            throw new ServiceException(DV_MACHINERY_CODE_DUPLICATE.getCode(),
                    "设备编号[" + code + "]已被设备[" + machinery.getName() + "]使用,请更换编号,或点击“自动生成”重新生成");
        }
    }
@@ -236,7 +238,7 @@
            // 3.1 æ ¡éªŒå¿…填字段
            String key = StrUtil.blankToDefault(importItem.getCode(), "第 " + currentIndex + " è¡Œ");
            if (StrUtil.isBlank(importItem.getCode())) {
                respVO.getFailureCodes().put(key, "设备编码不能为空");
                respVO.getFailureCodes().put(key, "设备编号不能为空");
                return;
            }
            if (StrUtil.isBlank(importItem.getName())) {
@@ -300,7 +302,7 @@
                respVO.getUpdateCodes().add(importItem.getCode());
            } else {
                // ä¸æ”¯æŒæ›´æ–°
                respVO.getFailureCodes().put(key, "设备编码已存在");
                respVO.getFailureCodes().put(key, "设备编号已存在,如需覆盖请勾选“是否覆盖”后重新导入");
            }
        });
        // è®°å½•导入结果汇总,供 @LogRecord çš„ success æ–‡æ¡ˆå¼•用
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImpl.java
@@ -78,6 +78,7 @@
        // æ’å…¥
        MesWmArrivalNoticeDO notice = BeanUtils.toBean(createReqVO, MesWmArrivalNoticeDO.class);
        fillContractNoFromPurchaseOrder(notice);
        notice.setStatus(MesWmArrivalNoticeStatusEnum.PREPARE.getStatus());
        arrivalNoticeMapper.insert(notice);
        return notice.getId();
@@ -92,7 +93,21 @@
        // æ›´æ–°
        MesWmArrivalNoticeDO updateObj = BeanUtils.toBean(updateReqVO, MesWmArrivalNoticeDO.class);
        fillContractNoFromPurchaseOrder(updateObj);
        arrivalNoticeMapper.updateById(updateObj);
    }
    /**
     * é‡‡è´­åˆåŒå·æœªæ˜¾å¼ä¼ å…¥æ—¶ï¼Œä»Žå…³è”的采购订单带入(换货到货必须能追溯到原采购合同)
     */
    private void fillContractNoFromPurchaseOrder(MesWmArrivalNoticeDO notice) {
        if (notice.getPurchaseOrderId() == null || cn.hutool.core.util.StrUtil.isNotBlank(notice.getContractNo())) {
            return;
        }
        ErpPurchaseOrderRespDTO purchaseOrder = purchaseOrderApi.getPurchaseOrder(notice.getPurchaseOrderId()).getCheckedData();
        if (purchaseOrder != null) {
            notice.setContractNo(purchaseOrder.getContractNo());
        }
    }
    private void validateArrivalNoticeSaveData(MesWmArrivalNoticeSaveReqVO reqVO) {
@@ -336,6 +351,7 @@
        notice.setName(purchaseOrder.getNo() + "到货通知");
        notice.setPurchaseOrderId(purchaseOrderId);
        notice.setPurchaseOrderCode(purchaseOrder.getNo());
        notice.setContractNo(purchaseOrder.getContractNo());
        notice.setVendorId(purchaseOrder.getSupplierId());
        notice.setArrivalDate(java.time.LocalDateTime.now()); // åˆ°è´§æ—¶é—´ä¸ºå½“前时间
        notice.setStatus(MesWmArrivalNoticeStatusEnum.PENDING_RECEIPT.getStatus());
@@ -414,6 +430,9 @@
        notice.setName(returnVendor.getCode() + "换货到货");
        notice.setPurchaseOrderId(returnVendor.getPurchaseOrderId());
        notice.setPurchaseOrderCode(returnVendor.getPurchaseOrderCode());
        // æ¢è´§é‡æ–°å…³è”原采购合同号:优先取退货单快照,退货单未快照时回填采购订单合同号
        notice.setContractNo(returnVendor.getContractNo());
        fillContractNoFromPurchaseOrder(notice);
        notice.setVendorId(returnVendor.getVendorId());
        notice.setArrivalDate(java.time.LocalDateTime.now());
        notice.setStatus(MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus());
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImpl.java
@@ -431,12 +431,36 @@
            receiptLine.setItemId(noticeLine.getItemId());
            // ä½¿ç”¨åˆæ ¼æ•°é‡ï¼ˆå¦‚果有 IQC åˆ™æ˜¯åˆæ ¼æ•°é‡ï¼Œå¦åˆ™æ˜¯åˆ°è´§æ•°é‡ï¼‰
            receiptLine.setReceivedQuantity(noticeLine.getQualifiedQuantity() != null ? noticeLine.getQualifiedQuantity() : noticeLine.getArrivalQuantity());
            // æŒ‰é‡‡è´­è®¢å•行单价快照金额,供来票/对账使用
            fillReceiptLinePrice(receiptLine);
            itemReceiptLineService.createItemReceiptLineFromArrivalNoticeLine(receiptLine);
        }
        return receipt.getId();
    }
    /**
     * æŒ‰é‡‡è´­è®¢å•行单价回填入库行金额,避免对账时再联表取价
     */
    private void fillReceiptLinePrice(MesWmItemReceiptLineDO receiptLine) {
        if (receiptLine.getPurchaseOrderItemId() == null) {
            return;
        }
        try {
            var item = purchaseOrderApi.getPurchaseOrderItem(receiptLine.getPurchaseOrderItemId()).getData();
            if (item == null || item.getProductPrice() == null) {
                return;
            }
            receiptLine.setPrice(item.getProductPrice());
            if (receiptLine.getReceivedQuantity() != null) {
                receiptLine.setTotalPrice(item.getProductPrice().multiply(receiptLine.getReceivedQuantity()));
            }
        } catch (Exception ex) {
            // ä»·æ ¼åªæ˜¯å¿«ç…§ï¼Œé‡‡è´­è®¢å•行取不到时不阻断入库
            log.warn("[fillReceiptLinePrice][采购订单行({}) ä»·æ ¼å¿«ç…§å¤±è´¥]", receiptLine.getPurchaseOrderItemId(), ex);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitItemReceiptApproval(Long id, String processDefinitionKey) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImpl.java
@@ -74,8 +74,31 @@
        if (noticeLine != null) {
            line.setSaleOrderItemId(noticeLine.getSaleOrderItemId());
        }
        fillProductSalesLinePrice(line);
        productSalesLineMapper.insert(line);
        return line.getId();
    }
    /**
     * æŒ‰é”€å”®è®¢å•行单价回填出库行金额,供开票/对账使用
     */
    private void fillProductSalesLinePrice(MesWmProductSalesLineDO line) {
        if (line.getSaleOrderItemId() == null) {
            return;
        }
        try {
            var item = saleOrderApi.getSaleOrderItem(line.getSaleOrderItemId()).getData();
            if (item == null || item.getProductPrice() == null) {
                return;
            }
            line.setPrice(item.getProductPrice());
            if (line.getQuantity() != null) {
                line.setTotalPrice(item.getProductPrice().multiply(line.getQuantity()));
            }
        } catch (Exception ex) {
            // ä»·æ ¼åªæ˜¯å¿«ç…§ï¼Œè®¢å•行取不到时不阻断开单
            log.warn("[fillProductSalesLinePrice][销售订单行({}) ä»·æ ¼å¿«ç…§å¤±è´¥]", line.getSaleOrderItemId(), ex);
        }
    }
    @Override
@@ -91,6 +114,7 @@
        if (noticeLine != null) {
            updateObj.setSaleOrderItemId(noticeLine.getSaleOrderItemId());
        }
        fillProductSalesLinePrice(updateObj);
        productSalesLineMapper.updateById(updateObj);
        // æäº¤å‡ºåº“单后由质检人员选择已完成的出厂检验单。
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java
@@ -4,6 +4,8 @@
import cn.hutool.core.util.ObjUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.aftersales.api.AfterSaleTicketApi;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleWarehouseReturnCreateReqDTO;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
@@ -58,6 +60,8 @@
    private MesMdmItemService mdmItemService;
    @Resource
    private MesWmTransactionService wmTransactionService;
    @Resource
    private AfterSaleTicketApi afterSaleTicketApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -69,6 +73,15 @@
        MesWmReturnSalesDO returnSales = BeanUtils.toBean(createReqVO, MesWmReturnSalesDO.class);
        returnSales.setStatus(MesWmReturnSalesStatusEnum.PREPARE.getStatus());
        returnSalesMapper.insert(returnSales);
        // 3. åŒæ­¥å…³è”售后工单:仓储退货留痕 + å·¥å•进入待退货
        AfterSaleWarehouseReturnCreateReqDTO returnReqDTO = new AfterSaleWarehouseReturnCreateReqDTO();
        returnReqDTO.setSaleOrderId(createReqVO.getSaleOrderId());
        returnReqDTO.setReturnSalesId(returnSales.getId());
        returnReqDTO.setReturnSalesCode(createReqVO.getCode());
        returnReqDTO.setReturnReason(createReqVO.getReturnReason());
        afterSaleTicketApi.onWarehouseReturnCreated(returnReqDTO);
        return returnSales.getId();
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnvendor/MesWmReturnVendorServiceImpl.java
@@ -8,6 +8,7 @@
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnvendor.vo.MesWmReturnVendorPageReqVO;
@@ -32,8 +33,7 @@
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -55,6 +55,7 @@
 */
@Service
@Validated
@Slf4j
public class MesWmReturnVendorServiceImpl implements MesWmReturnVendorService {
    /**
@@ -201,9 +202,13 @@
    @Transactional(rollbackFor = Exception.class)
    public void submitReturnVendorApproval(Long id, String processDefinitionKey) {
        // 1. æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€ + è‡³å°‘一条行
        validateReturnVendorExistsAndPrepare(id);
        MesWmReturnVendorDO returnVendor = validateReturnVendorExistsAndPrepare(id);
        if (CollUtil.isEmpty(returnVendorLineService.getReturnVendorLineListByReturnId(id))) {
            throw exception(WM_RETURN_VENDOR_NO_LINE);
        }
        // 1.1. è´¨é‡éƒ¨å‘起退货必须上传附件(质检报告、不合格照片等),否则审批人无法判断退货依据
        if (CollUtil.isEmpty(storageAttachmentApi.listAttachments(RETURN_VENDOR_RECORD_TYPE, id))) {
            throw exception(WM_RETURN_VENDOR_ATTACHMENT_REQUIRED, returnVendor.getCode());
        }
        // 2. å…¨å±€å…å®¡æ‰¹ï¼šç›´æŽ¥ç½®ä¸ºã€Œå¾…拣货」,无需流程模型
@@ -471,16 +476,16 @@
     * èŽ·å–å½“å‰ç”¨æˆ·ç¼–å·ï¼ˆBPM æµç¨‹å‘起人、财务人员)
     */
    private Long getCurrentUserId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.getPrincipal() instanceof Long) {
            return (Long) authentication.getPrincipal();
        }
        return null;
        // ç»Ÿä¸€ä»Žå®‰å…¨æ¡†æž¶å–登录用户;Spring Security çš„ principal æ˜¯ LoginUser è€Œéž Long,
        // ç›´æŽ¥å¼ºè½¬ä¼šå–到 null,导致 BPM æµç¨‹å®žä¾‹çš„发起人为字符串 "null"
        return SecurityFrameworkUtils.getLoginUserId();
    }
    private void createTransactionList(MesWmReturnVendorDO returnVendor, List<MesWmReturnVendorDetailDO> details) {
        wmTransactionService.createTransactionList(convertList(details, detail -> new MesWmTransactionSaveReqDTO()
                .setType(MesWmTransactionTypeEnum.OUT.getType()).setItemId(detail.getItemId())
                // æŒ‰æ‹£è´§æ—¶é€‰ä¸­çš„库存记录扣减:生产批次可能没有 vendor_id,仅靠 ç‰©æ–™+库位+批次+供应商 ç»„合键会找不到库存
                .setMaterialStockId(detail.getMaterialStockId())
                .setQuantity(detail.getQuantity().negate()) // å‡ºåº“数量为负数
                .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
                .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
@@ -581,8 +586,11 @@
        }
        try {
            importPurchaseOrderItems(id);
        } catch (ServiceException ignore) {
            // é‡‡è´­è®¢å•无明细 / æ— å¯é€€ç‰©æ–™ç­‰ä¸šåŠ¡å¼‚å¸¸ï¼šå•æ®ä¸»ä½“ä»æ­£å¸¸ä¿å­˜ï¼Œå¯åœ¨ç‰©æ–™åŒºå¦è¡Œå¤„ç†
        } catch (ServiceException ex) {
            // é‡‡è´­è®¢å•无明细 / æ— å¯é€€ç‰©æ–™ç­‰ä¸šåŠ¡å¼‚å¸¸ï¼šå•æ®ä¸»ä½“ä»æ­£å¸¸ä¿å­˜ï¼Œå¯åœ¨ç‰©æ–™åŒºç‚¹ã€Œé‡æ–°å¸¦å…¥è®¢å•ç‰©æ–™ã€è¡¥å¸¦
            // æ‰“日志便于排查(例如:保存后才入库,此时订单还没有可退数量)
            log.warn("[autoImportLinesFromLinkedOrder][退货单({}) å…³è”采购订单({}) è‡ªåŠ¨å¸¦å…¥ç‰©æ–™å¤±è´¥ï¼š{}]",
                    id, returnVendor.getPurchaseOrderId(), ex.getMessage());
        }
    }
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryServiceImpl.java
@@ -393,7 +393,8 @@
                orderItem.setCount(item.getQuantity());
                orderItem.setTaxPercent(quote.getTaxRate());
                orderItem.setRemark("SRM询价 " + inquiry.getNo());
                orderItem.setQcCheckFlag(false);
                // ä¸Žé‡‡è´­ç”³è¯·/采购台账一致:新生成的采购明细默认质检
                orderItem.setQcCheckFlag(true);
                orderItems.add(orderItem);
            }
            ErpPurchaseOrderCreateReqDTO createReqDTO = new ErpPurchaseOrderCreateReqDTO();
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java
@@ -33,6 +33,14 @@
    void updateUser(AdminUserUpdateReqDTO updateReqDTO);
    /**
     * æ›´æ–°ç”¨æˆ·çŠ¶æ€ï¼ˆå¼€å¯ / åœç”¨ï¼‰
     *
     * @param id     ç”¨æˆ·ç¼–号
     * @param status çŠ¶æ€ï¼Œè§ {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum}
     */
    void updateUserStatus(Long id, Integer status);
    /**
     * é€šè¿‡ç”¨æˆ· ID æŸ¥è¯¢ç”¨æˆ·
     *
     * @param id ç”¨æˆ·ID
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java
@@ -67,6 +67,11 @@
    }
    @Override
    public void updateUserStatus(Long id, Integer status) {
        userService.updateUserStatus(id, status);
    }
    @Override
    @DataPermission(enable = false) // å¿½ç•¥æ•°æ®æƒé™ï¼Œé¿å…å› ä¸ºè¿‡æ»¤ï¼Œå¯¼è‡´æ— æ³•查询用户。类似:https://github.com/YunaiV/ruoyi-vue-pro/issues/1051
    public AdminUserRespDTO getUser(Long id) {
        AdminUserDO user = userService.getUser(id);
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/notice/NoticeController.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.controller.admin.notice.vo.NoticePageReqVO;
import cn.iocoder.yudao.module.system.controller.admin.notice.vo.NoticeRespVO;
import cn.iocoder.yudao.module.system.controller.admin.notice.vo.NoticeSaveReqVO;
@@ -35,6 +36,9 @@
    @Resource
    private WebSocketSenderApi webSocketSenderApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建通知公告")
@@ -82,7 +86,11 @@
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    public CommonResult<NoticeRespVO> getNotice(@RequestParam("id") Long id) {
        NoticeDO notice = noticeService.getNotice(id);
        return success(BeanUtils.toBean(notice, NoticeRespVO.class));
        NoticeRespVO respVO = BeanUtils.toBean(notice, NoticeRespVO.class);
        if (respVO != null) {
            respVO.setAttachmentList(storageAttachmentApi.listAttachments("notice", id));
        }
        return success(respVO);
    }
    @PostMapping("/push")
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/notice/vo/NoticeRespVO.java
@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.system.controller.admin.notice.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - é€šçŸ¥å…¬å‘Šä¿¡æ¯ Response VO")
@Data
@@ -27,4 +29,7 @@
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式")
    private LocalDateTime createTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/notice/vo/NoticeSaveReqVO.java
@@ -7,6 +7,8 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
@Schema(description = "管理后台 - é€šçŸ¥å…¬å‘Šåˆ›å»º/修改 Request VO")
@Data
public class NoticeSaveReqVO {
@@ -29,4 +31,7 @@
    @Schema(description = "状态,参见 CommonStatusEnum æžšä¸¾ç±»", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Integer status;
    @Schema(description = "附件 blobId åˆ—表", example = "[1, 2, 3]")
    private List<Long> blobIds;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -29,6 +29,7 @@
    // ===== é€šçŸ¥å…¬å‘Š =====
    NOTICE("notice", "通知公告"),
    OA_NOTICE("oa_notice", "协同办公-通知公告"),
    // ===== å¤´åƒ =====
    AVATAR("avatar", "头像"),
@@ -59,12 +60,15 @@
    // ===== å”®åŽ =====
    AFTER_SALE_TICKET("aftersales_after_sale_ticket", "售后工单"),
    AFTER_SALE_TICKET_FEEDBACK("aftersales_after_sale_ticket_feedback", "售后工单-客户诉求反馈"),
    AFTER_SALE_TICKET_RESULT("aftersales_after_sale_ticket_result", "售后工单-处理结果"),
    AFTER_SALE_RETURN("aftersales_after_sale_return", "售后退货申请"),
    AFTER_SALE_VISIT("aftersales_after_sale_visit", "售后回访"),
    AFTER_SALE_PROBLEM("aftersales_after_sale_problem", "售后问题库"),
    // ===== MES =====
    MES_PD_DOCUMENT("mes_pd_document", "MES è®¾è®¡èµ„æ–™"),
    MES_WM_RETURN_VENDOR("mes_wm_return_vendor", "MES ä¾›åº”商退货单"),
    MDM_ITEM_TECHNICAL_FILE("mdm_item_technical_file", "MDM ç‰©æ–™æŠ€æœ¯æ–‡ä»¶"),
    MES_WORK_ORDER_TECHNICAL_FILE("mes_work_order_technical_file", "生产工单技术文件快照"),
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/notice/NoticeServiceImpl.java
@@ -2,6 +2,7 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.controller.admin.notice.vo.NoticePageReqVO;
import cn.iocoder.yudao.module.system.controller.admin.notice.vo.NoticeSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.notice.NoticeDO;
@@ -23,13 +24,22 @@
@Service
public class NoticeServiceImpl implements NoticeService {
    /** é™„件关联的业务记录类型 */
    private static final String RECORD_TYPE = "notice";
    /** é™„件应用用途 */
    private static final String APPLICATION = "file";
    @Resource
    private NoticeMapper noticeMapper;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    public Long createNotice(NoticeSaveReqVO createReqVO) {
        NoticeDO notice = BeanUtils.toBean(createReqVO, NoticeDO.class);
        noticeMapper.insert(notice);
        storageAttachmentApi.bindAttachments(APPLICATION, RECORD_TYPE, notice.getId(), createReqVO.getBlobIds());
        return notice.getId();
    }
@@ -40,19 +50,24 @@
        // æ›´æ–°é€šçŸ¥å…¬å‘Š
        NoticeDO updateObj = BeanUtils.toBean(updateReqVO, NoticeDO.class);
        noticeMapper.updateById(updateObj);
        storageAttachmentApi.updateAttachments(APPLICATION, RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    public void deleteNotice(Long id) {
        // æ ¡éªŒæ˜¯å¦å­˜åœ¨
        validateNoticeExists(id);
        // åˆ é™¤é€šçŸ¥å…¬å‘Š
        // åˆ é™¤é€šçŸ¥å…¬å‘ŠåŠå…¶é™„件关联
        noticeMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
    }
    @Override
    public void deleteNoticeList(List<Long> ids) {
        noticeMapper.deleteByIds(ids);
        for (Long id : ids) {
            storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
        }
    }
    @Override