2026-08-10 9fceeaad9af96bc58ba714560814c6e94f8af406
feat(crm): 增强合同开票与回款功能

- 集成开票服务到合同控制器,支持获取已开票金额和未开票金额
- 在合同响应对象中新增已开票金额和未开票金额字段
- 增强发票控制器功能,添加简单列表接口供回款创建时选择关联发票
- 更新发票金额统计逻辑,包含审批通过和审批中的发票状态
- 强化回款验证机制,增加发票关联验证和金额超限检查
- 在回款实体中新增关联发票编号字段
- 添加按发票ID查询回款金额映射的方法
- 实现回款与发票的双向关联显示功能
- 优化开票提交时的金额超限验证逻辑
- 增加发票附件上传状态标记和剩余可回款金额计算
- 在ERP模块中新增采购来票业务类型枚举值
- 实现ERP付款单与采购发票的关联功能
已修改28个文件
已添加25个文件
3182 ■■■■■ 文件已修改
docs/contract_invoice_amount.md 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/hrm_employee_contract_integration.md 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/purchase_invoice_payment_link.md 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/receivable_invoice_link.md 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_employee_contract_init.sql 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmInvoiceParseFunction.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpFinancePaymentController.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentSaveReqVO.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoicePageReqVO.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpFinancePaymentDO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceDO.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseInvoiceAuditStatusEnum.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/common/ErpBizTypeEnum.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentServiceImpl.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/listener/ErpPurchaseInvoiceStatusListener.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeContractController.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractPageReqVO.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractRespVO.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractSaveReqVO.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractTerminateReqVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/employee/HrmEmployeeContractDO.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeContractMapper.java 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/HrmNoRedisDAO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractService.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractServiceImpl.java 321 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/contract_invoice_amount.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
# åˆåŒç®¡ç†æ–°å¢ž"已开票金额/未开票金额"字段 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- åˆåŒç®¡ç†åˆ—表页
- åˆåŒè¯¦æƒ…页
- åˆ›å»ºå¼€ç¥¨ / åˆ›å»ºå›žæ¬¾æ—¶é€‰æ‹©åˆåŒçš„下拉(simple-list)
## API
本次**不改动任何接口路径、方法、请求参数**,仅合同相关接口的**响应结构新增两个字段**。
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /crm/contract/page | åˆåŒåˆ†é¡µåˆ—表(新增字段) |
| GET | /crm/contract/get | åˆåŒè¯¦æƒ…(新增字段) |
| GET | /crm/contract/page-by-customer | æŒ‰å®¢æˆ·åˆ†é¡µï¼ˆæ–°å¢žå­—段) |
| GET | /crm/contract/page-by-business | æŒ‰å•†æœºåˆ†é¡µï¼ˆæ–°å¢žå­—段) |
| GET | /crm/contract/simple-list | åˆåŒä¸‹æ‹‰ï¼ˆæ–°å¢žå­—段) |
**响应字段新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| totalInvoicePrice | BigDecimal | å·²å¼€ç¥¨é‡‘额(元),统计**审批通过 + å®¡æ‰¹ä¸­**的开票单金额合计 |
| unInvoicePrice | BigDecimal | æœªå¼€ç¥¨é‡‘额(元),等于 `totalPrice - totalInvoicePrice`,仅列表/详情接口返回,simple-list ä¸è¿”回 |
**响应示例(列表/详情):**
```json
{
  "code": 0,
  "data": {
    "totalPrice": 1000000.00,
    "totalReceivablePrice": 400000.00,
    "totalInvoicePrice": 1000000.00,
    "unInvoicePrice": 0.00
  }
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| å·²å¼€ç¥¨é‡‘额(totalInvoicePrice) | åˆ—表表格列、详情页金额区域 | å«å®¡æ‰¹ä¸­çš„开票单金额,与后端防超校验口径一致 |
| æœªå¼€ç¥¨é‡‘额(unInvoicePrice) | åˆ—表表格列、详情页金额区域 | åˆåŒé‡‘额 - å·²å¼€ç¥¨é‡‘额,可同时用于展示"剩余可开票金额" |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å®¡æ‰¹ä¸­çš„开票单 | è®¡å…¥"已开票金额",占用合同额度(与后端提交开票时的校验一致,前端展示的剩余额度才准确) |
| å®¡æ‰¹è¢«é©³å›ž / å–消的开票单 | ä¸è®¡å…¥"已开票金额",额度自动释放 |
| æœªå¼€ç¥¨é‡‘额 | è‹¥ä¸ºè´Ÿæ•°è¯´æ˜Žå·²è¶…开(正常流程后端已拦截,不会出现) |
| åˆåŒä¸‹æ‹‰ï¼ˆsimple-list) | è¿”回 totalInvoicePrice,可用于在创建开票/回款时提示剩余可开金额 |
## æ³¨æ„äº‹é¡¹
- å­—段语义是"含审批中",不是"实际已开出票"金额,前端展示时文案建议用"已开票金额(含审批中)"避免歧义。
- æ–°å¢žå­—段仅用于展示,前端**不要**提交这两个字段(后端不接收,属只读汇总字段)。
- Excel å¯¼å‡ºæœ¬æ¬¡æœªæ–°å¢žè¿™ä¸¤åˆ—,如需导出可另行申请。
docs/hrm_employee_contract_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,182 @@
# å‘˜å·¥åˆåŒç®¡ç† - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- æ–°å¢žã€Œå‘˜å·¥åˆåŒã€ç®¡ç†é¡µï¼š`views/hrm/employee/contract/index.vue`
- æ–°å¢žã€Œå‘˜å·¥åˆåŒã€èœå•(已配置后端菜单,挂载在 HRM â†’ äººåŠ›èµ„æº ä¸‹ã€ä¸Žã€Œå‘˜å·¥ç®¡ç†ã€å¹³çº§ï¼Œcomponent = `hrm/employee/contract/index`;不要挂到「员工管理」下,否则该菜单会变成目录导致员工列表页无法直达)
- é€‰æ‹©å‘˜å·¥ä¸‹æ‹‰ï¼šå¤ç”¨çŽ°æœ‰å‘˜å·¥ç²¾ç®€åˆ—è¡¨æŽ¥å£
- å‘˜å·¥è¯¦æƒ…页:新增「员工合同」Tab,展示该员工签订的全部合同(复用 `/hrm/employee-contract/page`,按 `employeeId` è¿‡æ»¤ï¼‰
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. åˆ›å»ºåˆåŒï¼šå‘˜å·¥ä»Ž `/hrm/employee/simple-list` ä¸‹æ‹‰é€‰æ‹© â†’ è‡ªåŠ¨å¸¦å…¥å‘˜å·¥ï¼ˆæ— éœ€å†é€‰éƒ¨é—¨/岗位)
2. ç»­ç­¾åˆåŒï¼šåˆåŒåˆ—表操作列「续签」按钮 â†’ è‡ªåŠ¨å¸¦å…¥è¯¥å‘˜å·¥ + ä¸Šä»½åˆåŒçš„类型/期限/签约主体/工资,日期需重新填写;提交时显式传 `parentId` æŒ‡å‘被续签合同
3. åˆåŒåˆ—表:按合同编号、员工、合同类型、期限类型、合同状态、到期类型筛选
3. è§£é™¤/终止:仅对"待生效/生效中/即将到期"的合同可操作,弹出确认框填写解除日期与原因
4. åˆ°æœŸæé†’:列表支持「即将到期 / å·²åˆ°æœŸã€å¿«æ·ç­›é€‰ï¼›HRM é¦–页可调用 `expiring-count` å±•示即将到期合同数量
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /hrm/employee-contract/create | æ–°å¢žåˆåŒ |
| PUT | /hrm/employee-contract/update | ä¿®æ”¹åˆåŒ |
| PUT | /hrm/employee-contract/terminate | è§£é™¤/终止合同 |
| DELETE | /hrm/employee-contract/delete | åˆ é™¤åˆåŒ |
| GET | /hrm/employee-contract/get | åˆåŒè¯¦æƒ…(含附件) |
| GET | /hrm/employee-contract/page | åˆåŒåˆ†é¡µ |
| GET | /hrm/employee-contract/expiring-count | å³å°†åˆ°æœŸåˆåŒæ•°é‡ï¼ˆä¾›å¾…办/首页提醒) |
### åˆ›å»º/修改合同
**请求参数(create/update å…±ç”¨ï¼‰ï¼š**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | å¦ | åˆåŒID(修改时必传) |
| employeeId | Long | æ˜¯ | å‘˜å·¥ID |
| parentId | Long | å¦ | ç»­ç­¾ä¸Šä¸€ä»½åˆåŒID。**续签按钮**点击时前端会传(指向被续签的合同);普通新增不传,后端自动关联该员工最近一份合同 |
| contractType | Integer | æ˜¯ | åˆåŒç±»åž‹ï¼Œå­—å…¸ hrm_contract_type:1-劳动合同 2-劳务合同 3-实习协议 4-劳务派遣 5-其他 |
| contractTermType | Integer | æ˜¯ | æœŸé™ç±»åž‹ï¼Œå­—å…¸ hrm_contract_term_type:1-固定期限 2-无固定期限 3-以完成一定工作任务为期限 |
| signCompany | String | å¦ | ç­¾çº¦ä¸»ä½“(公司全称) |
| signDate | date | å¦ | ç­¾è®¢æ—¥æœŸ |
| startDate | date | æ˜¯ | åˆåŒå¼€å§‹æ—¥æœŸ |
| endDate | date | å¦ | åˆåŒç»“束日期(无固定期限可为空) |
| probationStartDate | date | å¦ | è¯•用期开始日期 |
| probationEndDate | date | å¦ | è¯•用期结束日期 |
| probationSalary | BigDecimal | å¦ | è¯•用期工资 |
| regularSalary | BigDecimal | å¦ | è½¬æ­£å·¥èµ„ |
| remark | String | å¦ | å¤‡æ³¨ |
| blobIds | Array | å¦ | åˆåŒé™„ä»¶ blobId åˆ—表 |
**校验规则(后端强制):**
- æœŸé™ç±»åž‹ â‰  æ— å›ºå®šæœŸé™(2) æ—¶ï¼ŒendDate å¿…å¡«
- startDate ä¸èƒ½æ™šäºŽ endDate;试用期开始不能晚于试用期结束
- åˆåŒç¼–号由后端自动生成(HT + æ—¥æœŸ + åºå·ï¼‰ï¼Œå‰ç«¯æ— éœ€å¡«å†™ã€ä¸å¯ä¿®æ”¹
**响应示例:**
```json
{
  "code": 0,
  "data": 1
}
```
### è§£é™¤/终止合同
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | åˆåŒID |
| terminateStatus | Integer | æ˜¯ | 1-已解除 2-已终止 |
| terminateDate | date | æ˜¯ | è§£é™¤/终止日期 |
| terminateReason | String | æ˜¯ | è§£é™¤/终止原因 |
**响应:** `{ "code": 0, "data": true }`
### åˆåŒåˆ†é¡µ
**请求参数(筛选):**
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| pageNo / pageSize | Integer | åˆ†é¡µå‚æ•° |
| contractNo | String | åˆåŒç¼–号(模糊匹配) |
| employeeId | Long | å‘˜å·¥ID |
| contractType | Integer | åˆåŒç±»åž‹ |
| contractTermType | Integer | æœŸé™ç±»åž‹ |
| terminateStatus | Integer | 0-正常 1-已解除 2-已终止 |
| status | Integer | åˆåŒçŠ¶æ€ï¼ˆåŠ¨æ€è®¡ç®—ï¼‰ï¼š1-待生效 2-生效中 3-即将到期 4-已到期 5-已解除 6-已终止 |
| expiryType | Integer | åˆ°æœŸç±»åž‹ï¼š1-即将到期 2-已到期(待办提醒用,与 status äº’斥) |
**响应字段新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| contractNo | String | åˆåŒç¼–号(自动生成) |
| parentId | Long | ç»­ç­¾ä¸Šä¸€ä»½åˆåŒID |
| parentNo | String | ç»­ç­¾ä¸Šä¸€ä»½åˆåŒç¼–号(前端展示"续签自") |
| isCurrent | Boolean | æ˜¯å¦å‘˜å·¥å½“前生效合同(true æ—¶å‰ç«¯æ‰“标) |
| employeeName | String | å‘˜å·¥å§“名(自动填充) |
| employeeNo | String | å‘˜å·¥å·¥å·ï¼ˆè‡ªåŠ¨å¡«å……ï¼‰ |
| deptName | String | éƒ¨é—¨åç§°ï¼ˆè‡ªåŠ¨å¡«å……ï¼‰ |
| status | Integer | åˆåŒçŠ¶æ€ï¼ˆåŽç«¯æŒ‰å½“å‰æ—¥æœŸåŠ¨æ€è®¡ç®—ï¼‰ |
| attachmentList | Array | é™„件列表(仅详情接口返回) |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 1,
        "contractNo": "HT202608100001",
        "employeeId": 1,
        "employeeName": "张三",
        "employeeNo": "EMP202401010001",
        "deptName": "研发部",
        "contractType": 1,
        "contractTermType": 1,
        "startDate": "2026-01-01",
        "endDate": "2027-01-01",
        "status": 2,
        "terminateStatus": 0
      }
    ],
    "total": 1
  }
}
```
## å­—段展示规则
| å­—段 | åˆ—表 | è¡¨å• | è¯¦æƒ… | è¯´æ˜Ž |
|------|------|------|------|------|
| åˆåŒç¼–号 | âœ… | åªè¯»è‡ªåŠ¨ç”Ÿæˆ | âœ… | HT + æ—¥æœŸ + åºå· |
| å‘˜å·¥å§“名 | âœ… | ä¸‹æ‹‰é€‰æ‹© | âœ… | å¤ç”¨å‘˜å·¥ç²¾ç®€åˆ—表 |
| éƒ¨é—¨åç§° | âœ… | è‡ªåЍ另入 | âœ… | é€‰æ‹©å‘˜å·¥åŽå±•示 |
| åˆåŒç±»åž‹ | âœ… | ä¸‹æ‹‰ | âœ… | å­—å…¸ hrm_contract_type |
| æœŸé™ç±»åž‹ | âœ… | ä¸‹æ‹‰ | âœ… | å­—å…¸ hrm_contract_term_type |
| ç­¾è®¢æ—¥æœŸ | âœ… | æ—¥æœŸ | âœ… | |
| å¼€å§‹/结束日期 | âœ… | æ—¥æœŸ | âœ… | æ— å›ºå®šæœŸé™ç»“束日期可空 |
| è¯•用期起止 | âœ… | æ—¥æœŸ | âœ… | |
| è¯•用期/转正工资 | âœ… | æ•°å­— | âœ… | |
| åˆåŒçŠ¶æ€ | âœ… æ ‡ç­¾ | - | âœ… | åŠ¨æ€è®¡ç®—ï¼ŒæŒ‰é¢œè‰²åŒºåˆ†ï¼šç”Ÿæ•ˆä¸­(绿)/即将到期(橙)/已到期(灰)/已解除·已终止(红)/待生效(蓝) |
| é™„ä»¶ | - | ä¸Šä¼  | âœ… | èµ°ç³»ç»Ÿé™„件上传 |
| æ“ä½œ | âœ… | - | - | ç¼–辑 / è§£é™¤/终止 / åˆ é™¤ |
## é™„件对接流程
1. ä¸Šä¼ ï¼šè°ƒç”¨ `POST /system/storage-blob/upload` èŽ·å– blobId
2. ä¿å­˜åˆåŒï¼šè¡¨å• `blobIds` å­—段传 blobId æ•°ç»„,后端绑定
3. æŸ¥è¯¢ï¼šè¯¦æƒ…接口返回 `attachmentList`;列表页如需单独刷新附件,可调用 `GET /system/storage-attachment/list?recordType=hrm_employee_contract&recordId=xxx`
4. ä¸šåŠ¡è¡¨ä¸å­˜æ–‡ä»¶åœ°å€ï¼Œç¦æ­¢åœ¨å‰ç«¯è°ƒç”¨ `/infra/file/*` æ—§æŽ¥å£
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
> **多合同设计**:一个员工可签订多次合同(续签/换签)。数据表通过 `parent_id`(续签上一份合同ID)形成续签链,通过 `is_current`(是否当前生效合同)标识员工当前合同;同一员工任意时刻仅一份 `is_current=true` çš„合同。
| åœºæ™¯ | è§„则 |
|------|------|
| çŠ¶æ€è®¡ç®— | å·²è§£é™¤(5)/已终止(6) > å·²åˆ°æœŸ(4) > å¾…生效(1) > å³å°†åˆ°æœŸ(3) > ç”Ÿæ•ˆä¸­(2),后端按当前日期动态计算,前端无需维护 |
| å³å°†åˆ°æœŸ | ç»“束日期在当天起的 30 å¤©å†…(含当天)且未解除/终止 |
| ç»­ç­¾å…³è” | æ“ä½œåˆ—「续签」按钮点击时前端显式传 `parent_id` æŒ‡å‘被续签合同;普通新增不传,后端自动把该员工最近一份合同作为 `parent_id`(续签链)。前端列表/详情展示"续签自 xxx" |
| å½“前合同 | åˆ›å»ºæ–°åˆåŒæ—¶ï¼ŒåŽç«¯è‡ªåŠ¨æ¸…é™¤è¯¥å‘˜å·¥æ—§åˆåŒçš„ `is_current` å¹¶ç½®æ–°åˆåŒä¸º true;解除/终止当前合同时自动清除标记 |
| æ—¶é—´é‡å  | åŽç«¯å¼ºåˆ¶æ ¡éªŒï¼šåŒä¸€å‘˜å·¥ä¸èƒ½å­˜åœ¨æ—¶é—´é‡å çš„æœªè§£é™¤/未终止合同(无固定期限视为无限长),报错"该员工在所选时间段已存在生效合同",需先解除/终止旧合同或调整日期 |
| è§£é™¤/终止 | ä»…未解除/未终止的合同可操作;已解除/已终止合同不可重复操作;操作后状态固定为已解除/已终止 |
| ç¼–辑限制 | å·²è§£é™¤/已终止的合同建议只读展示,不允许修改起止日期(后端未强制,前端提示即可) |
| åˆ é™¤ | ç›´æŽ¥åˆ é™¤è®°å½•并级联删除附件 |
| åˆ°æœŸæé†’ | åˆ—表「即将到期」筛选 = status=3 æˆ– expiryType=1;首页可通过 expiring-count å±•示数量 |
| å‘˜å·¥ç»´åº¦ | åˆåŒé¡µæŒ‰å‘˜å·¥ç­›é€‰å¯æŸ¥çœ‹æŸå‘˜å·¥å…¨éƒ¨åˆåŒï¼›å‘˜å·¥è¯¦æƒ…页「员工合同」Tab å±•示该员工全部合同并标记当前合同 |
## æ³¨æ„äº‹é¡¹
- åˆåŒç¼–号唯一,由后端生成,前端不能修改
- æ–°å¢ž/编辑合同的校验(固定期限必须填结束日期、日期先后)由后端返回明确错误提示,前端表单校验可同步加上
- åˆ—表状态列需根据 status å€¼æ˜ å°„å­—å…¸ hrm_contract_status å±•示中文与颜色
- è§£é™¤/终止操作需二次确认,避免误操作
- åˆ†é¡µæŸ¥è¯¢é™„件列表为空时不返回该字段,前端需做空值兼容
docs/purchase_invoice_payment_link.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,155 @@
# é‡‡è´­æ¥ç¥¨ + ä»˜æ¬¾å…³è”来票 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- é‡‡è´­æ¥ç¥¨ç®¡ç†é¡µï¼ˆ**新建页面**,参考采购申请/采购订单页面结构)
- é‡‡è´­è®¢å•详情页(展示该订单下已关联的来票信息)
- ä»˜æ¬¾å•管理页(新建/编辑弹窗、列表、详情)
- ä»˜æ¬¾å•详情页(展示关联的来票信息)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. é‡‡è´­è®¢å•(审核通过)-> åˆ›å»ºæ¥ç¥¨ï¼š`purchaseOrderId` å¿…选,供应商自动带出(以采购订单的供应商为准,前端无需传 `supplierId`,由后端按采购订单查询设置)。
2. æ¥ç¥¨åˆ›å»ºåŽ -> ä¸Šä¼ å‘票附件(走 system æ¨¡å— Storage API)。
3. æ¥ç¥¨æäº¤å®¡æ‰¹ -> å®¡æ‰¹é€šè¿‡ -> æ‰å¯ä½œä¸ºä»˜æ¬¾å…³è”对象。
4. æ–°å»ºä»˜æ¬¾å• -> é€‰æ‹©å…³è”来票(`invoiceId` å¿…填):从 `GET /erp/purchase-invoice/simple-list?supplierId=&purchaseOrderId=` é€‰æ‹©ï¼Œä»…显示"审批通过 + å·²ä¸Šä¼ é™„ä»¶ + å‰©ä½™å¯ä»˜é‡‘额 > 0"的来票;选择后 `remainingInvoicePrice` å¯ç”¨äºŽé¢„填付款金额上限。
对应关系:**采购订单(1) -> æ¥ç¥¨(N) -> ä»˜æ¬¾(M)**。
## API
### 1. æ¥ç¥¨ç®¡ç†ï¼ˆæ–°ï¼‰
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /erp/purchase-invoice/create | åˆ›å»ºæ¥ç¥¨ |
| PUT | /erp/purchase-invoice/update | æ›´æ–°æ¥ç¥¨ |
| DELETE | /erp/purchase-invoice/delete?id= | åˆ é™¤æ¥ç¥¨ |
| GET | /erp/purchase-invoice/get?id= | èŽ·å¾—æ¥ç¥¨è¯¦æƒ… |
| GET | /erp/purchase-invoice/page | æ¥ç¥¨åˆ†é¡µ |
| GET | /erp/purchase-invoice/export-excel | å¯¼å‡ºæ¥ç¥¨ Excel |
| PUT | /erp/purchase-invoice/submit?id= | æäº¤æ¥ç¥¨å®¡æ‰¹ |
| GET | /erp/purchase-invoice/audit-count | å¾…审核来票数量 |
| GET | /erp/purchase-invoice/simple-list?supplierId=&purchaseOrderId= | ç²¾ç®€åˆ—表(付款表单选来票) |
**创建/更新来票请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | å¦ | æ›´æ–°æ—¶å¿…å¡« |
| purchaseOrderId | Long | æ˜¯ | é‡‡è´­è®¢å•编号,必选 |
| supplierId | Long | å¦ | ä¾›åº”商编号,**不传**,由后端按采购订单查询设置 |
| invoiceNo | String | å¦ | å‘票号码 |
| invoiceTitle | String | å¦ | å‘票抬头 |
| price | BigDecimal | æ˜¯ | æ¥ç¥¨é‡‘额(元) |
| invoiceTime | LocalDateTime | æ˜¯ | æ¥ç¥¨æ—¥æœŸ |
| remark | String | å¦ | å¤‡æ³¨ |
| blobIds | List\<Long\> | å¦ | å‘票附件 blobId åˆ—表 |
**来票分页/详情响应新增字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| no | String | æ¥ç¥¨ç¼–号(后端生成,前缀 FPD) |
| supplierName | String | ä¾›åº”商名称 |
| order | Object | é‡‡è´­è®¢å•信息(嵌套) |
| auditStatus | Integer | å®¡æ‰¹çŠ¶æ€ï¼ˆ0未提交/10审批中/20审核通过/30审核不通过/40已取消) |
| attachmentList | List | å‘票附件列表 |
| hasAttachment | Boolean | æ˜¯å¦å·²ä¸Šä¼ å‘票附件 |
| remainingInvoicePrice | BigDecimal | å‰©ä½™å¯ä»˜é‡‘额 = æ¥ç¥¨é‡‘额 - è¯¥æ¥ç¥¨å·²ä»˜æ¬¾é‡‘额 |
**simple-list å“åº”:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | Long | æ¥ç¥¨ç¼–号 |
| no | String | æ¥ç¥¨ç¼–号 |
| invoiceNo | String | å‘票号码 |
| invoiceTitle | String | å‘票抬头 |
| price | BigDecimal | æ¥ç¥¨é‡‘额 |
| supplierId | Long | ä¾›åº”商编号 |
| purchaseOrderId | Long | é‡‡è´­è®¢å•编号 |
| auditStatus | Integer | å®¡æ‰¹çŠ¶æ€ |
| hasAttachment | Boolean | æ˜¯å¦å·²ä¸Šä¼ é™„件(恒为 true) |
| remainingInvoicePrice | BigDecimal | å‰©ä½™å¯ä»˜é‡‘额 |
### 2. ä»˜æ¬¾å•(接口变更)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /erp/finance-payment/create | åˆ›å»ºä»˜æ¬¾å•,请求体新增 `invoiceId`(必填) |
| PUT | /erp/finance-payment/update | æ›´æ–°ä»˜æ¬¾å•,请求体新增 `invoiceId`(必填) |
| GET | /erp/finance-payment/get | å“åº”新增 `invoiceId`、`invoice` åµŒå¥—对象 |
| GET | /erp/finance-payment/page | å“åº”新增 `invoiceId`、`invoice` åµŒå¥—对象 |
**请求参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| invoiceId | Long | æ˜¯ | å…³è”的来票编号(新建付款单必填) |
**响应字段新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| invoiceId | Long | å…³è”的来票编号 |
| invoice | Object | å…³è”的来票信息(含 no、invoiceNo、invoiceTitle、price、auditStatus ç­‰ï¼‰ |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "id": 1,
    "no": "FKD20260810000001",
    "invoiceId": 100,
    "invoice": {
      "id": 100,
      "no": "FPD20260810000001",
      "invoiceNo": "02564178",
      "invoiceTitle": "XX科技有限公司",
      "price": 9000.00,
      "auditStatus": 20
    },
    "paymentPrice": 5000.00
  }
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| æ¥ç¥¨ç¼–号 | æ¥ç¥¨åˆ—表、详情、付款详情 | å‰ç¼€ FPD |
| ä¾›åº”商 | æ¥ç¥¨åˆ—表、详情 | æŒ‰é‡‡è´­è®¢å•带出 |
| é‡‡è´­è®¢å• | æ¥ç¥¨åˆ—表、详情 | å±•示订单编号/信息 |
| å‘票号码/抬头 | æ¥ç¥¨åˆ—表、详情 | å½•入字段 |
| æ¥ç¥¨é‡‘额 | æ¥ç¥¨åˆ—表、详情、付款表单 | å…ƒ |
| æ¥ç¥¨æ—¥æœŸ | æ¥ç¥¨åˆ—表、详情 | |
| å®¡æ‰¹çŠ¶æ€ | æ¥ç¥¨åˆ—表、详情 | å­—典展示 |
| å‘票附件 | æ¥ç¥¨è¯¦æƒ…、来票列表 | ä¸Šä¼ /预览/下载 |
| å‰©ä½™å¯ä»˜é‡‘额 | æ¥ç¥¨åˆ—表、付款表单 | æ¥ç¥¨é‡‘额 - å·²ä»˜æ¬¾é‡‘额 |
| å…³è”来票 | ä»˜æ¬¾åˆ—表、付款详情 | å±•示 invoice.no / invoice.invoiceNo / invoice.price |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ¥ç¥¨åˆ›å»º | é‡‡è´­è®¢å•必须审核通过;来票供应商与采购订单一致;来票金额不超过订单剩余可开票金额 |
| æ¥ç¥¨ç¼–辑 | ä»…"未提交"状态可编辑 |
| æ¥ç¥¨åˆ é™¤ | å®¡æ‰¹é€šè¿‡åŽä¸å¯åˆ é™¤ |
| æ¥ç¥¨æäº¤å®¡æ‰¹ | ä»…"未提交"状态可提交,提交后进入审批中 |
| ä»˜æ¬¾é€‰æ‹©æ¥ç¥¨ | ä»…审批通过 + å·²ä¸Šä¼ å‘票附件的来票可选 |
| ä»˜æ¬¾é‡‘额上限 | ä»˜æ¬¾é‡‘额 <= è¯¥æ¥ç¥¨é‡‘额 - è¯¥æ¥ç¥¨å·²ä»˜æ¬¾é‡‘额(未审核+审批中+已审核付款,排除作废) |
| ä¾›åº”商一致性 | ä»˜æ¬¾çš„供应商必须与关联来票的供应商一致 |
| å­˜é‡ä»˜æ¬¾å• | invoiceId ä¸ºç©ºçš„存量付款单,编辑时需重新选择合格来票 |
## æ³¨æ„äº‹é¡¹
- æ¥ç¥¨ä¾›åº”商不从前端传入,创建/更新时由后端根据采购订单自动设置,前端表单只展示。
- ä»˜æ¬¾è¡¨å•选择来票时,先按供应商过滤(`supplierId`),可选订单时传 `purchaseOrderId` è¿›ä¸€æ­¥è¿‡æ»¤ã€‚
- `remainingInvoicePrice` ä¸ºè´Ÿæ•°æˆ– 0 çš„æ¥ç¥¨ä¸ä¼šå‡ºçŽ°åœ¨ simple-list ä¸­ï¼Œå‰ç«¯å¯ç›´æŽ¥æŒ‰è¿”回列表渲染。
- ä»˜æ¬¾é‡‘额校验在后端,前端可将来票的 `remainingInvoicePrice` ä½œä¸ºä»˜æ¬¾é‡‘额输入上限提示。
- æ¥ç¥¨å®¡æ‰¹çŠ¶æ€å˜åŒ–é€šè¿‡ BPM å›žè°ƒæ›´æ–°ï¼Œå‰ç«¯æ— éœ€å¤„理,分页接口直接返回最新状态。
- æ¥ç¥¨å‘票附件上传使用 system æ¨¡å— Storage API(upload -> bind),不要使用旧版 /infra/file æŽ¥å£ã€‚
docs/receivable_invoice_link.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,119 @@
# å¼€ç¥¨ä¸Žå›žæ¬¾å…³è”(先开票后回款) - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- å›žæ¬¾ç®¡ç†åˆ—表页
- å›žæ¬¾åˆ›å»º / ç¼–辑表单
- å›žæ¬¾è¯¦æƒ…页
- å¼€ç¥¨ç®¡ç†åˆ—表页、开票详情页(**前端目前缺失,需新建**)
- åˆåŒç®¡ç†åˆ—表页、合同详情页(上个任务已接入"已开票金额/未开票金额",本次不涉及)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **开票 â†’ å›žæ¬¾**:开票单审批通过 + ä¸Šä¼ å‘票附件后,才会出现在回款创建表单的"关联开票"下拉中。
2. **回款创建**:选择客户 â†’ é€‰æ‹©åˆåŒ â†’ é€‰æ‹©å…³è”开票(自动带入客户、合同)→(可选)选择回款计划 â†’ å¡«å›žæ¬¾é‡‘额(可预填为该开票的剩余可回款金额)。
3. **合同下拉(simple-list)**:已返回 `totalInvoicePrice`(已开票金额,含审批中),用于展示合同剩余可开额度。
4. **开票下拉(simple-list)**:返回 `remainingInvoicePrice`(该开票剩余可回款金额),用于创建回款时预填金额。
## API
### 1. æ–°å¢žæŽ¥å£ï¼šæŒ‰åˆåŒæŸ¥è¯¢å¯é€‰å¼€ç¥¨
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/crm/invoice/simple-list?contractId=` | å›žæ¬¾åˆ›å»ºæ—¶é€‰æ‹©å…³è”开票,只返回合格开票 |
**筛选规则(后端已实现)**:审批通过 + å·²ä¸Šä¼ å‘票附件 + å‰©ä½™å¯å›žæ¬¾é‡‘额 > 0。
**响应示例:**
```json
{
  "code": 0,
  "data": [
    {
      "id": 1,
      "no": "KP20260810000001",
      "invoiceNo": "02564178",
      "invoiceTitle": "XX科技有限公司",
      "price": 1090.00,
      "customerId": 1,
      "contractId": 31,
      "auditStatus": 20,
      "hasAttachment": true,
      "remainingInvoicePrice": 1090.00
    }
  ]
}
```
### 2. å˜æ›´æŽ¥å£ï¼šå›žæ¬¾åˆ›å»º / æ›´æ–°
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/crm/receivable/create` | è¯·æ±‚体新增 `invoiceId`(**必填**) |
| PUT | `/crm/receivable/update` | è¯·æ±‚体新增 `invoiceId`(可修改) |
**请求参数新增:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| invoiceId | Long | æ˜¯ | å…³è”的开票编号,必须为审批通过且已上传附件的开票 |
### 3. å˜æ›´æŽ¥å£ï¼šå›žæ¬¾æŸ¥è¯¢ï¼ˆå“åº”新增字段)
| æ–¹æ³• | è·¯å¾„ | æ–°å¢žå“åº”字段 |
|------|------|--------------|
| GET | `/crm/receivable/page` | `invoiceId`、`invoice`(嵌套对象) |
| GET | `/crm/receivable/get` | `invoiceId`、`invoice`(嵌套对象) |
| GET | `/crm/receivable/page-by-customer` | `invoiceId`、`invoice`(嵌套对象) |
**`invoice` åµŒå¥—对象关键字段:** `id`、`no`(开票编号)、`invoiceNo`(发票号码)、`invoiceTitle`(发票抬头)、`price`(开票金额)。
### 4. å˜æ›´æŽ¥å£ï¼šå¼€ç¥¨æŸ¥è¯¢ï¼ˆå“åº”新增字段)
| æ–¹æ³• | è·¯å¾„ | æ–°å¢žå“åº”字段 |
|------|------|--------------|
| GET | `/crm/invoice/page` | `hasAttachment`、`remainingInvoicePrice` |
| GET | `/crm/invoice/get` | `hasAttachment`、`remainingInvoicePrice` |
**说明:**
- `hasAttachment`(Boolean):是否已上传发票附件,用于列表快速判断哪些开票可被回款关联。
- `remainingInvoicePrice`(BigDecimal):剩余可回款金额 = å¼€ç¥¨é‡‘额 - è¯¥å¼€ç¥¨å·²å›žæ¬¾é‡‘额(统计草稿 + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡çš„回款)。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| å…³è”开票 | å›žæ¬¾åˆ—表列、回款详情页 | å±•示 `invoice.no` / `invoice.invoiceNo` / `invoice.price` |
| å…³è”开票选择器 | å›žæ¬¾åˆ›å»º/编辑表单 | é€‰æ‹©åŽå¸¦å…¥å®¢æˆ·ã€åˆåŒã€é¢„填回款金额为 `remainingInvoicePrice` |
| å‰©ä½™å¯å›žæ¬¾é‡‘额 | å¼€ç¥¨åˆ—表列、开票详情页 | å¼€ç¥¨é‡‘额 - è¯¥å¼€ç¥¨å·²å›žæ¬¾é‡‘额 |
| æ˜¯å¦å·²ä¸Šä¼ é™„ä»¶ | å¼€ç¥¨åˆ—表列 | è¾…助判断开票是否可回款(需审批通过 + å·²ä¸Šä¼ é™„件) |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å®¡æ‰¹ä¸­çš„开票 | ä¸å¯é€‰ä¸ºå›žæ¬¾ä¾æ®ï¼ˆåŽç«¯æ‹¦æˆªï¼šå…³è”开票不是审批通过状态) |
| æœªä¸Šä¼ å‘票附件的开票 | ä¸å¯é€‰ä¸ºå›žæ¬¾ä¾æ®ï¼ˆåŽç«¯æ‹¦æˆªï¼šå…³è”开票未上传发票附件) |
| å·²å…¨éƒ¨å›žæ¬¾çš„开票 | ä»Ž"关联开票"下拉过滤(剩余可回款 â‰¤ 0) |
| å›žæ¬¾é‡‘额超限 | åŽç«¯æ‹¦æˆªï¼šå›žæ¬¾é‡‘额超出该开票剩余可回款金额 |
| å¼€ç¥¨ä¸ŽåˆåŒä¸åŒ¹é… | åŽç«¯æ‹¦æˆªï¼šæ‰€é€‰å¼€ç¥¨ä¸Žå›žæ¬¾åˆåŒä¸ä¸€è‡´ |
| ç¼–辑回款时改开票 | è‰ç¨¿/审批中状态可修改关联开票,改后重新按新开票校验金额 |
## å‰ç«¯ç¼ºå£ï¼ˆéœ€æ–°å»ºï¼‰
> åŽç«¯å¼€ç¥¨æ¨¡å—已完整(含审批流程、附件上传),但前端尚无开票页面与 API,需前端新建。
1. **开票管理页**:`views/crm/invoice/`(列表、创建/编辑、审批提交、发票附件上传与展示)。
2. **开票 API**:`api/crm/invoice/`(page / get / create / update / delete / submit / audit-count / export-excel / simple-list)。
3. **回款表单开票选择器**:选合同后调用 `/crm/invoice/simple-list?contractId=`,展示"开票编号 / å‘票号码 / å¼€ç¥¨é‡‘额 / å‰©ä½™å¯å›žæ¬¾",选中后预填金额。
4. **菜单**:已由后端 SQL é…ç½®å®Œæˆï¼ˆè¥é”€ç®¡ç† -> å¼€ç¥¨ç®¡ç†ï¼Œ`component = crm/invoice/index`),前端建好页面后菜单即可用。
## æ³¨æ„äº‹é¡¹
- `invoiceId` ä¸ºå¿…填,回款创建/更新**必须**携带,否则返回参数校验错误。
- `invoice`、`remainingInvoicePrice`、`hasAttachment` å‡ä¸ºåŽç«¯è®¡ç®—的只读字段,前端**不要**提交。
- å›žæ¬¾é‡‘额建议在选中开票后预填为 `remainingInvoicePrice`,避免超限被后端拦截。
- å­˜é‡å›žæ¬¾æ•°æ®è‹¥æœªå…³è”开票(`invoiceId` ä¸ºç©ºï¼‰ï¼Œç¼–辑时会被要求重新选择合格开票。
- å¼€ç¥¨åˆ—表展示"剩余可回款金额"时,文案建议用"剩余可回款金额(含审批中)"避免歧义,与合同"已开票金额(含审批中)"口径一致。
sql/mysql/hrm_employee_contract_init.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
-- =============================================
-- HRM å‘˜å·¥åˆåŒç®¡ç† SQL åˆå§‹åŒ–脚本
-- åŒ…含:建表 + æ•°æ®å­—å…¸ + èœå•
-- =============================================
-- =============================================
-- 1. å‘˜å·¥åˆåŒè¡¨
-- =============================================
CREATE TABLE IF NOT EXISTS `hrm_employee_contract` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '合同ID',
    `parent_id` bigint DEFAULT NULL COMMENT '续签上一份合同ID(一个员工可签订多次合同,通过该字段形成续签链)',
    `is_current` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否当前生效合同(员工最新签订的未解除/未终止合同)',
    `contract_no` varchar(64) NOT NULL COMMENT '合同编号',
    `employee_id` bigint NOT NULL COMMENT '员工ID',
    `contract_type` tinyint NOT NULL COMMENT '合同类型:1-劳动合同,2-劳务合同,3-实习协议,4-劳务派遣,5-其他',
    `contract_term_type` tinyint NOT NULL COMMENT '合同期限类型:1-固定期限,2-无固定期限,3-以完成一定工作任务为期限',
    `sign_company` varchar(128) DEFAULT NULL COMMENT '签约主体(公司全称)',
    `sign_date` date DEFAULT NULL COMMENT '签订日期',
    `start_date` date NOT NULL COMMENT '合同开始日期',
    `end_date` date DEFAULT NULL COMMENT '合同结束日期(无固定期限可为空)',
    `probation_start_date` date DEFAULT NULL COMMENT '试用期开始日期',
    `probation_end_date` date DEFAULT NULL COMMENT '试用期结束日期',
    `probation_salary` decimal(10,2) DEFAULT NULL COMMENT '试用期工资',
    `regular_salary` decimal(10,2) DEFAULT NULL COMMENT '转正工资',
    `terminate_status` tinyint NOT NULL DEFAULT 0 COMMENT '解除/终止状态:0-正常,1-已解除,2-已终止',
    `terminate_date` date DEFAULT NULL COMMENT '解除/终止日期',
    `terminate_reason` varchar(255) DEFAULT NULL COMMENT '解除/终止原因',
    `remark` varchar(255) 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`),
    UNIQUE KEY `uk_contract_no` (`contract_no`),
    KEY `idx_employee_id` (`employee_id`),
    KEY `idx_end_date` (`end_date`),
    KEY `idx_terminate_status` (`terminate_status`),
    KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HRM员工合同表';
-- =============================================
-- 1.1 å¢žé‡å‡çº§ï¼ˆé’ˆå¯¹å·²å­˜åœ¨çš„表执行,注意 ADD COLUMN ä¸æ”¯æŒ IF NOT EXISTS,需确认列不存在)
-- =============================================
ALTER TABLE `hrm_employee_contract`
    ADD COLUMN `parent_id` bigint DEFAULT NULL COMMENT '续签上一份合同ID' AFTER `id`,
    ADD COLUMN `is_current` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否当前生效合同' AFTER `parent_id`,
    ADD INDEX `idx_parent_id` (`parent_id`);
-- =============================================
-- 2. æ•°æ®å­—典类型
-- =============================================
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1061123, '合同类型', 'hrm_contract_type', 0, NULL, '', NOW(), '', NOW(), b'0'),
(1061124, '合同期限类型', 'hrm_contract_term_type', 0, NULL, '', NOW(), '', NOW(), b'0'),
(1061125, '合同状态', 'hrm_contract_status', 0, NULL, '', NOW(), '', NOW(), b'0'),
(1061126, '解除终止状态', 'hrm_contract_terminate_status', 0, NULL, '', NOW(), '', NOW(), b'0');
-- =============================================
-- 3. æ•°æ®å­—典数据
-- =============================================
-- 3.1 åˆåŒç±»åž‹ hrm_contract_type
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1061231, 1, '劳动合同', '1', 'hrm_contract_type', 0, 'primary', NULL, '', NOW(), '', NOW(), b'0'),
(1061232, 2, '劳务合同', '2', 'hrm_contract_type', 0, 'success', NULL, '', NOW(), '', NOW(), b'0'),
(1061233, 3, '实习协议', '3', 'hrm_contract_type', 0, 'warning', NULL, '', NOW(), '', NOW(), b'0'),
(1061234, 4, '劳务派遣', '4', 'hrm_contract_type', 0, 'info', NULL, '', NOW(), '', NOW(), b'0'),
(1061235, 5, '其他', '5', 'hrm_contract_type', 0, 'default', NULL, '', NOW(), '', NOW(), b'0');
-- 3.2 åˆåŒæœŸé™ç±»åž‹ hrm_contract_term_type
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1061236, 1, '固定期限', '1', 'hrm_contract_term_type', 0, 'primary', NULL, '', NOW(), '', NOW(), b'0'),
(1061237, 2, '无固定期限', '2', 'hrm_contract_term_type', 0, 'success', NULL, '', NOW(), '', NOW(), b'0'),
(1061238, 3, '以完成一定工作任务为期限', '3', 'hrm_contract_term_type', 0, 'warning', NULL, '', NOW(), '', NOW(), b'0');
-- 3.3 åˆåŒçŠ¶æ€ hrm_contract_status(动态计算展示用)
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1061239, 1, '待生效', '1', 'hrm_contract_status', 0, 'info', NULL, '', NOW(), '', NOW(), b'0'),
(1061240, 2, '生效中', '2', 'hrm_contract_status', 0, 'success', NULL, '', NOW(), '', NOW(), b'0'),
(1061241, 3, '即将到期', '3', 'hrm_contract_status', 0, 'warning', NULL, '', NOW(), '', NOW(), b'0'),
(1061242, 4, '已到期', '4', 'hrm_contract_status', 0, 'default', NULL, '', NOW(), '', NOW(), b'0'),
(1061243, 5, '已解除', '5', 'hrm_contract_status', 0, 'danger', NULL, '', NOW(), '', NOW(), b'0'),
(1061244, 6, '已终止', '6', 'hrm_contract_status', 0, 'danger', NULL, '', NOW(), '', NOW(), b'0');
-- 3.4 è§£é™¤ç»ˆæ­¢çŠ¶æ€ hrm_contract_terminate_status
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1061245, 1, '正常', '0', 'hrm_contract_terminate_status', 0, 'success', NULL, '', NOW(), '', NOW(), b'0'),
(1061246, 2, '已解除', '1', 'hrm_contract_terminate_status', 0, 'danger', NULL, '', NOW(), '', NOW(), b'0'),
(1061247, 3, '已终止', '2', 'hrm_contract_terminate_status', 0, 'warning', NULL, '', NOW(), '', NOW(), b'0');
-- =============================================
-- 4. èœå•(挂载到 HRM äººåŠ›èµ„æº 6001 ä¸‹ï¼Œä¸Žå‘˜å·¥ç®¡ç† 6006 å¹³çº§ï¼‰
-- æ³¨æ„ï¼šä¸èƒ½æŒ‚在"员工管理"下,否则 6006 ä¼šå˜æˆå¸¦å­èœå•的目录,导致员工列表页无法直达
-- =============================================
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `path`, `component`, `parent_id`, `icon`, `sort`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES
(1063105, '员工合同', '', 2, 'employee-contract', 'hrm/employee/contract/index', 6001, 'ep:files', 0, 0, b'1', b'1', b'1', '', NOW(), '', NOW(), b'0'),
(1063106, '合同查询', 'hrm:employee-contract:query', 3, '', '', 1063105, '#', 1, 0, b'1', b'1', b'1', '', NOW(), '', NOW(), b'0'),
(1063107, '合同新增', 'hrm:employee-contract:create', 3, '', '', 1063105, '#', 2, 0, b'1', b'1', b'1', '', NOW(), '', NOW(), b'0'),
(1063108, '合同修改', 'hrm:employee-contract:update', 3, '', '', 1063105, '#', 3, 0, b'1', b'1', b'1', '', NOW(), '', NOW(), b'0'),
(1063109, '合同删除', 'hrm:employee-contract:delete', 3, '', '', 1063105, '#', 4, 0, b'1', b'1', b'1', '', NOW(), '', NOW(), b'0');
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
@@ -26,6 +26,7 @@
import cn.iocoder.yudao.module.crm.service.contact.CrmContactService;
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;
import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
@@ -71,6 +72,8 @@
    private CrmBusinessService businessService;
    @Resource
    private CrmReceivableService receivableService;
    @Resource
    private CrmInvoiceService invoiceService;
    @Resource
    private MdmItemApi mdmItemApi;
@@ -218,6 +221,9 @@
        // 1.5 èŽ·å¾—å·²å›žæ¬¾é‡‘é¢
        Map<Long, BigDecimal> receivablePriceMap = receivableService.getReceivablePriceMapByContractId(
                convertSet(contractList, CrmContractDO::getId));
        // 1.6 èŽ·å¾—å·²å¼€ç¥¨é‡‘é¢ï¼ˆå®¡æ‰¹é€šè¿‡ + å®¡æ‰¹ä¸­ï¼Œä¸Žå¼€ç¥¨é˜²è¶…校验口径一致)
        Map<Long, BigDecimal> invoicePriceMap = invoiceService.getInvoicePriceMapByContractId(
                convertSet(contractList, CrmContractDO::getId));
        // 2. æ‹¼æŽ¥æ•°æ®
        return BeanUtils.toBean(contractList, CrmContractRespVO.class, contractVO -> {
            // 2.1 è®¾ç½®å®¢æˆ·ä¿¡æ¯
@@ -235,6 +241,12 @@
            findAndThen(businessMap, contractVO.getBusinessId(), business -> contractVO.setBusinessName(business.getName()));
            // 2.5 è®¾ç½®å·²å›žæ¬¾é‡‘额
            contractVO.setTotalReceivablePrice(receivablePriceMap.getOrDefault(contractVO.getId(), BigDecimal.ZERO));
            // 2.6 è®¾ç½®å·²å¼€ç¥¨é‡‘额
            contractVO.setTotalInvoicePrice(invoicePriceMap.getOrDefault(contractVO.getId(), BigDecimal.ZERO));
            // 2.7 è®¾ç½®æœªå¼€ç¥¨é‡‘额
            if (contractVO.getTotalPrice() != null && contractVO.getTotalInvoicePrice() != null) {
                contractVO.setUnInvoicePrice(contractVO.getTotalPrice().subtract(contractVO.getTotalInvoicePrice()));
            }
        });
    }
@@ -266,10 +278,13 @@
        // æ‹¼æŽ¥æ•°æ®
        Map<Long, BigDecimal> receivablePriceMap = receivableService.getReceivablePriceMapByContractId(
                convertSet(pageResult.getList(), CrmContractDO::getId));
        Map<Long, BigDecimal> invoicePriceMap = invoiceService.getInvoicePriceMapByContractId(
                convertSet(pageResult.getList(), CrmContractDO::getId));
        return success(convertList(pageResult.getList(), contract -> new CrmContractRespVO() // åªè¿”回 id、name ç­‰ç²¾ç®€å­—段
                .setId(contract.getId()).setName(contract.getName()).setAuditStatus(contract.getAuditStatus())
                .setTotalPrice(contract.getTotalPrice())
                .setTotalReceivablePrice(receivablePriceMap.getOrDefault(contract.getId(), BigDecimal.ZERO))));
                .setTotalReceivablePrice(receivablePriceMap.getOrDefault(contract.getId(), BigDecimal.ZERO))
                .setTotalInvoicePrice(invoicePriceMap.getOrDefault(contract.getId(), BigDecimal.ZERO))));
    }
    @PostMapping("/generate-sale-order")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java
@@ -96,6 +96,12 @@
    @ExcelProperty(value = "未回款金额(元)", index = 13)
    private BigDecimal unReceivablePrice;
    @Schema(description = "已开票金额(含审批中),统计审批通过 + å®¡æ‰¹ä¸­çš„开票", example = "5617")
    private BigDecimal totalInvoicePrice;
    @Schema(description = "未开票金额,等于 åˆåŒé‡‘额 - å·²å¼€ç¥¨é‡‘额", example = "1000.00")
    private BigDecimal unInvoicePrice;
    @Schema(description = "关联 ERP é”€å”®è®¢å•编号", example = "12345")
    private Long orderId;
    @Schema(description = "ERP é”€å”®è®¢å•单号", example = "SO001")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java
@@ -16,14 +16,17 @@
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.enums.common.CrmAuditStatusEnum;
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;
import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
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 cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -35,6 +38,8 @@
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -60,6 +65,8 @@
    private CrmContractService contractService;
    @Resource
    private CrmCustomerService customerService;
    @Resource
    private CrmReceivableService receivableService;
    @Resource
    private AdminUserApi adminUserApi;
@@ -151,6 +158,9 @@
        // 1.3 èŽ·å¾—åˆåŒåˆ—è¡¨
        Map<Long, CrmContractDO> contractMap = contractService.getContractMap(
                convertSet(invoiceList, CrmInvoiceDO::getContractId));
        // 1.4 èŽ·å¾—æ¯å¼ å¼€ç¥¨å·²å›žæ¬¾é‡‘é¢ï¼ˆè‰ç¨¿ + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡ï¼‰
        Map<Long, BigDecimal> receivablePriceMap = receivableService.getReceivablePriceMapByInvoiceId(
                convertSet(invoiceList, CrmInvoiceDO::getId));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(invoiceList, CrmInvoiceRespVO.class, (invoiceVO) -> {
            // 2.1 æ‹¼æŽ¥å®¢æˆ·åç§°
@@ -166,7 +176,13 @@
            findAndThen(contractMap, invoiceVO.getContractId(), contract ->
                    invoiceVO.setContract(BeanUtils.toBean(contract, CrmContractRespVO.class)));
            // 2.4 æ‹¼æŽ¥å‘票附件
            invoiceVO.setAttachmentList(storageAttachmentApi.listAttachments("crm_invoice", invoiceVO.getId()));
            invoiceVO.setAttachmentList(storageAttachmentApi.listAttachments(
                    StorageRecordTypeEnum.CRM_INVOICE.getType(), invoiceVO.getId()));
            // 2.5 æ‹¼æŽ¥æ˜¯å¦å·²ä¸Šä¼ é™„ä»¶
            invoiceVO.setHasAttachment(CollUtil.isNotEmpty(invoiceVO.getAttachmentList()));
            // 2.6 æ‹¼æŽ¥å‰©ä½™å¯å›žæ¬¾é‡‘额
            invoiceVO.setRemainingInvoicePrice(invoiceVO.getPrice().subtract(
                    receivablePriceMap.getOrDefault(invoiceVO.getId(), BigDecimal.ZERO)));
        });
    }
@@ -185,4 +201,41 @@
        return success(invoiceService.getAuditInvoiceCount(getLoginUserId()));
    }
    @GetMapping("/simple-list")
    @Operation(summary = "获得开票精简列表", description = "只包含审核通过且已上传发票附件的开票,主要用于回款创建时选择关联开票")
    @Parameter(name = "contractId", description = "合同编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:invoice:query')")
    public CommonResult<List<CrmInvoiceRespVO>> getInvoiceSimpleList(@RequestParam("contractId") Long contractId) {
        List<CrmInvoiceDO> invoices = invoiceService.getInvoiceListByContractIdAndStatus(contractId,
                List.of(CrmAuditStatusEnum.APPROVE.getStatus()));
        if (CollUtil.isEmpty(invoices)) {
            return success(Collections.emptyList());
        }
        // è¿‡æ»¤ï¼šå·²ä¸Šä¼ å‘票附件
        invoices.removeIf(invoice -> CollUtil.isEmpty(storageAttachmentApi.listAttachments(
                StorageRecordTypeEnum.CRM_INVOICE.getType(), invoice.getId())));
        if (CollUtil.isEmpty(invoices)) {
            return success(Collections.emptyList());
        }
        // è®¡ç®—每张开票已回款金额(草稿 + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡ï¼Œå£å¾„与创建回款校验一致)
        Map<Long, BigDecimal> receivablePriceMap = receivableService.getReceivablePriceMapByInvoiceId(
                convertSet(invoices, CrmInvoiceDO::getId));
        // è¿‡æ»¤ï¼šå‰©ä½™å¯å›žæ¬¾é‡‘额 > 0 æ‰å¯é€‰
        List<CrmInvoiceRespVO> result = new ArrayList<>();
        for (CrmInvoiceDO invoice : invoices) {
            BigDecimal remainingPrice = invoice.getPrice()
                    .subtract(receivablePriceMap.getOrDefault(invoice.getId(), BigDecimal.ZERO));
            if (remainingPrice.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            result.add(new CrmInvoiceRespVO() // åªè¿”回精简字段
                    .setId(invoice.getId()).setNo(invoice.getNo()).setInvoiceNo(invoice.getInvoiceNo())
                    .setInvoiceTitle(invoice.getInvoiceTitle()).setPrice(invoice.getPrice())
                    .setCustomerId(invoice.getCustomerId()).setContractId(invoice.getContractId())
                    .setAuditStatus(invoice.getAuditStatus()).setHasAttachment(true)
                    .setRemainingInvoicePrice(remainingPrice));
        }
        return success(result);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java
@@ -85,6 +85,12 @@
    @Schema(description = "发票附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "是否已上传发票附件", example = "true")
    private Boolean hasAttachment;
    @Schema(description = "剩余可回款金额,等于 å¼€ç¥¨é‡‘额 - è¯¥å¼€ç¥¨å·²å›žæ¬¾é‡‘额(草稿+审批中+审批通过)", example = "1000.00")
    private BigDecimal remainingInvoicePrice;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.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.CrmInvoiceRespVO;
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;
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.CrmReceivableDO;
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;
import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
@@ -59,6 +62,8 @@
    private CrmContractService contractService;
    @Resource
    private CrmCustomerService customerService;
    @Resource
    private CrmInvoiceService invoiceService;
    @Resource
    private AdminUserApi adminUserApi;
@@ -148,6 +153,9 @@
        // 1.3 èŽ·å¾—åˆåŒåˆ—è¡¨
        Map<Long, CrmContractDO> contractMap = contractService.getContractMap(
                convertSet(receivableList, CrmReceivableDO::getContractId));
        // 1.4 èŽ·å¾—å…³è”å¼€ç¥¨åˆ—è¡¨
        Map<Long, CrmInvoiceDO> invoiceMap = invoiceService.getInvoiceMap(
                convertSet(receivableList, CrmReceivableDO::getInvoiceId));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(receivableList, CrmReceivableRespVO.class, (receivableVO) -> {
            // 2.1 æ‹¼æŽ¥å®¢æˆ·åç§°
@@ -162,6 +170,9 @@
            // 2.3 æ‹¼æŽ¥åˆåŒä¿¡æ¯
            findAndThen(contractMap, receivableVO.getContractId(), contract ->
                    receivableVO.setContract(BeanUtils.toBean(contract, CrmContractRespVO.class)));
            // 2.4 æ‹¼æŽ¥å…³è”开票信息
            findAndThen(invoiceMap, receivableVO.getInvoiceId(), invoice ->
                    receivableVO.setInvoice(BeanUtils.toBean(invoice, CrmInvoiceRespVO.class)));
        });
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
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.enums.DictTypeConstants;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
@@ -54,6 +55,12 @@
    @Schema(description = "合同信息")
    private CrmContractRespVO contract;
    @Schema(description = "关联的开票编号", example = "25787")
    @ExcelProperty("关联开票编号")
    private Long invoiceId;
    @Schema(description = "关联开票信息")
    private CrmInvoiceRespVO invoice;
    @Schema(description = "负责人的用户编号", example = "25682")
    private Long ownerUserId;
    @Schema(description = "负责人名字", example = "25682")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java
@@ -32,6 +32,11 @@
    @NotNull(message = "合同编号不能为空")
    private Long contractId;
    @Schema(description = "关联的开票编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25787")
    @DiffLogField(name = "关联开票", function = CrmInvoiceParseFunction.NAME)
    @NotNull(message = "关联开票编号不能为空")
    private Long invoiceId;
    @Schema(description = "回款计划编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @DiffLogField(name = "合同", function = CrmReceivablePlanParseFunction.NAME)
    private Long planId;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
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.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableReturnTypeEnum;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
@@ -57,6 +58,12 @@
     */
    private Long contractId;
    /**
     * å…³è”的开票编号
     *
     * å…³è” {@link CrmInvoiceDO#getId()},只有审批通过且已上传发票附件的开票才可关联
     */
    private Long invoiceId;
    /**
     * è´Ÿè´£äººç¼–号,关联 {@link AdminUserRespDTO#getId()}
     */
    private Long ownerUserId;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java
@@ -76,10 +76,11 @@
        if (CollUtil.isEmpty(contractIds)) {
            return Collections.emptyMap();
        }
        // SQL sum æŸ¥è¯¢
        // SQL sum æŸ¥è¯¢ï¼ˆå®¡æ‰¹é€šè¿‡ + å®¡æ‰¹ä¸­ï¼Œä¸Žå¼€ç¥¨é˜²è¶…校验口径一致)
        List<Map<String, Object>> result = selectMaps(new QueryWrapper<CrmInvoiceDO>()
                .select("contract_id, SUM(price) AS total_price")
                .eq("audit_status", CrmAuditStatusEnum.APPROVE.getStatus())
                .in("audit_status", CrmAuditStatusEnum.APPROVE.getStatus(),
                        CrmAuditStatusEnum.PROCESS.getStatus())
                .groupBy("contract_id")
                .in("contract_id", contractIds));
        // èŽ·å¾—é‡‘é¢
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
@@ -75,6 +75,27 @@
                .in(CrmReceivableDO::getAuditStatus, auditStatuses));
    }
    default List<CrmReceivableDO> selectListByInvoiceIdAndStatus(Long invoiceId, Collection<Integer> auditStatuses) {
        return selectList(new LambdaQueryWrapperX<CrmReceivableDO>()
                .eq(CrmReceivableDO::getInvoiceId, invoiceId)
                .in(CrmReceivableDO::getAuditStatus, auditStatuses));
    }
    default Map<Long, BigDecimal> selectReceivablePriceMapByInvoiceId(Collection<Long> invoiceIds) {
        if (CollUtil.isEmpty(invoiceIds)) {
            return Collections.emptyMap();
        }
        // SQL sum æŸ¥è¯¢
        List<Map<String, Object>> result = selectMaps(new QueryWrapper<CrmReceivableDO>()
                .select("invoice_id, SUM(price) AS total_price")
                .in("audit_status", CrmAuditStatusEnum.DRAFT.getStatus(), // è‰ç¨¿ + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡
                        CrmAuditStatusEnum.PROCESS.getStatus(), CrmAuditStatusEnum.APPROVE.getStatus())
                .groupBy("invoice_id")
                .in("invoice_id", invoiceIds));
        // èŽ·å¾—é‡‘é¢
        return convertMap(result, obj -> (Long) obj.get("invoice_id"), obj -> (BigDecimal) obj.get("total_price"));
    }
    default Map<Long, BigDecimal> selectReceivablePriceMapByContractId(Collection<Long> contractIds) {
        if (CollUtil.isEmpty(contractIds)) {
            return Collections.emptyMap();
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -128,6 +128,10 @@
    ErrorCode INVOICE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_020_016_005, "更新开票审核状态失败,原因:开票不是审核中状态");
    ErrorCode INVOICE_CREATE_FAIL_CONTRACT_NOT_APPROVE = new ErrorCode(1_020_016_006, "创建开票失败,原因:合同不是审核通过状态");
    ErrorCode INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT = new ErrorCode(1_020_016_007, "创建开票失败,原因:开票金额超出合同金额,目前可开票:{} å…ƒ");
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_NOT_ENOUGH = new ErrorCode(1_020_016_008, "创建回款失败,原因:合同累计开票金额不足,目前可回款:{} å…ƒ");
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_NOT_EXISTS = new ErrorCode(1_020_016_008, "创建回款失败,原因:关联的开票不存在");
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE = new ErrorCode(1_020_016_009, "创建回款失败,原因:关联的开票不是审批通过状态");
    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, "创建回款失败,原因:回款金额超出该开票剩余可回款金额,目前可回款:{} å…ƒ");
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmInvoiceParseFunction.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.crm.framework.operatelog.core;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.service.invoice.CrmInvoiceService;
import com.mzt.logapi.service.IParseFunction;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * CRM å¼€ç¥¨çš„ {@link IParseFunction} å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Component
@Slf4j
public class CrmInvoiceParseFunction implements IParseFunction {
    public static final String NAME = "getInvoiceById";
    @Resource
    private CrmInvoiceService invoiceService;
    @Override
    public boolean executeBefore() {
        return false; // ä¸åœ¨ _DIFF ä¸­ä½¿ç”¨ï¼Œæ— éœ€æå‰æ±‚值
    }
    @Override
    public String functionName() {
        return NAME;
    }
    @Override
    public String apply(Object value) {
        if (StrUtil.isEmptyIfStr(value)) {
            return "";
        }
        CrmInvoiceDO invoice = invoiceService.getInvoice(Long.parseLong(value.toString()));
        return invoice == null ? "" : invoice.getNo();
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java
@@ -121,7 +121,7 @@
    List<CrmInvoiceDO> getInvoiceListByContractIdAndStatus(Long contractId, Collection<Integer> auditStatuses);
    /**
     * èŽ·å¾—åˆåŒå·²å¼€ç¥¨é‡‘é¢ Map(仅审批通过)
     * èŽ·å¾—åˆåŒå·²å¼€ç¥¨é‡‘é¢ Map(审批通过 + å®¡æ‰¹ä¸­ï¼Œä¸Žå¼€ç¥¨é˜²è¶…校验口径一致)
     *
     * @param contractIds åˆåŒç¼–号
     * @return å¼€ç¥¨é‡‘额 Map
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
@@ -119,10 +119,10 @@
    }
    private void validateInvoicePriceExceedsLimit(CrmInvoiceSaveReqVO reqVO) {
        // 1. è®¡ç®—剩余可开票金额,不包括 reqVO è‡ªèº«
        // 1. è®¡ç®—剩余可开票金额,不包括 reqVO è‡ªèº«ï¼ˆå®¡æ‰¹ä¸­ä¹Ÿå ç”¨é¢åº¦ï¼Œé˜²æ­¢ç´¯è®¡å¼€ç¥¨è¶…过合同金额)
        CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
        List<CrmInvoiceDO> invoices = invoiceMapper.selectListByContractIdAndStatus(reqVO.getContractId(),
                List.of(CrmAuditStatusEnum.APPROVE.getStatus()));
                List.of(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus()));
        if (reqVO.getId() != null) {
            invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), reqVO.getId()));
        }
@@ -232,6 +232,10 @@
            throw exception(INVOICE_SUBMIT_FAIL_NOT_DRAFT);
        }
        // 1.1 é‡æ–°æ ¡éªŒå¯å¼€ç¥¨é‡‘额,防止累计开票超过合同金额(草稿期后可能有其他单进入审批中)
        validateInvoicePriceExceedsLimit(new CrmInvoiceSaveReqVO()
                .setId(invoice.getId()).setContractId(invoice.getContractId()).setPrice(invoice.getPrice()));
        // 2. åˆ›å»ºå¼€ç¥¨å®¡æ‰¹æµç¨‹å®žä¾‹
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id)));
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java
@@ -123,6 +123,14 @@
    Map<Long, BigDecimal> getReceivablePriceMapByContractId(Collection<Long> contractIds);
    /**
     * èŽ·å¾—å¼€ç¥¨å·²å›žæ¬¾é‡‘é¢ Map(草稿 + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡ï¼‰
     *
     * @param invoiceIds å¼€ç¥¨ç¼–号
     * @return å·²å›žæ¬¾é‡‘额 Map
     */
    Map<Long, BigDecimal> getReceivablePriceMapByInvoiceId(Collection<Long> invoiceIds);
    /**
     * æ ¹æ®åˆåŒç¼–号查询回款数量
     *
     * @param contractId åˆåŒç¼–号
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
@@ -91,13 +91,15 @@
    @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_CREATE_SUB_TYPE, bizNo = "{{#receivable.id}}",
            success = CRM_RECEIVABLE_CREATE_SUCCESS)
    public Long createReceivable(CrmReceivableSaveReqVO createReqVO) {
        // 1.1 æ ¡éªŒå¯å›žæ¬¾é‡‘额超过上限
        validateReceivablePriceExceedsLimit(createReqVO);
        // 1.2 æ ¡éªŒå¯å›žæ¬¾é‡‘额不超过累计已开票金额(先开票后回款)
        validateReceivablePriceExceedsInvoiceLimit(createReqVO);
        // 1.3 æ ¡éªŒå…³è”数据存在
        // 1.1 æ ¡éªŒå…³è”数据存在
        validateRelationDataExists(createReqVO);
        // 1.4 ç”Ÿæˆå›žæ¬¾ç¼–号
        // 1.2 æ ¡éªŒå…³è”开票合格(审批通过 + å·²ä¸Šä¼ å‘票附件 + ä¸ŽåˆåŒåŒ¹é…ï¼‰
        validateInvoiceForReceivable(createReqVO);
        // 1.3 æ ¡éªŒå¯å›žæ¬¾é‡‘额不超过合同剩余可回款金额
        validateReceivablePriceExceedsLimit(createReqVO);
        // 1.4 æ ¡éªŒå¯å›žæ¬¾é‡‘额不超过该开票剩余可回款金额(先开票后回款)
        validateReceivablePriceExceedsInvoiceRemaining(createReqVO);
        // 1.5 ç”Ÿæˆå›žæ¬¾ç¼–号
        String no = noRedisDAO.generate(CrmNoRedisDAO.RECEIVABLE_PREFIX);
        if (receivableMapper.selectByNo(no) != null) {
            throw exception(RECEIVABLE_NO_EXISTS);
@@ -128,8 +130,10 @@
    private void validateReceivablePriceExceedsLimit(CrmReceivableSaveReqVO reqVO) {
        // 1. è®¡ç®—剩余可退款金额,不包括 reqVO è‡ªèº«
        CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
        // ç»Ÿè®¡å£å¾„:草稿 + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡ï¼ˆä¸Žå¼€ç¥¨çº§æ ¡éªŒã€ç»Ÿè®¡æ–¹æ³• selectReceivablePriceMapByContractId ä¿æŒä¸€è‡´ï¼‰
        List<CrmReceivableDO> receivables = receivableMapper.selectListByContractIdAndStatus(reqVO.getContractId(),
                Arrays.asList(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus()));
                Arrays.asList(CrmAuditStatusEnum.DRAFT.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus(),
                        CrmAuditStatusEnum.APPROVE.getStatus()));
        if (reqVO.getId() != null) {
            receivables.removeIf(receivable -> ObjectUtil.equal(receivable.getId(), reqVO.getId()));
        }
@@ -142,33 +146,52 @@
    }
    /**
     * æ ¡éªŒç´¯è®¡å›žæ¬¾é‡‘额不超过累计已开票且已上传发票附件的金额(先开票后回款)
     * æ ¡éªŒå…³è”开票合格:存在、审批通过、已上传发票附件、与所选合同一致
     */
    private void validateReceivablePriceExceedsInvoiceLimit(CrmReceivableSaveReqVO reqVO) {
        // 1. è®¡ç®—合同累计回款金额(不含本次,统计口径同合同金额校验:审批通过 + å®¡æ‰¹ä¸­ï¼‰
        List<CrmReceivableDO> receivables = receivableMapper.selectListByContractIdAndStatus(reqVO.getContractId(),
                Arrays.asList(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus()));
    private void validateInvoiceForReceivable(CrmReceivableSaveReqVO reqVO) {
        // 1. æ ¡éªŒå¼€ç¥¨å­˜åœ¨
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        if (invoice == null) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_EXISTS);
        }
        // 2. æ ¡éªŒå¼€ç¥¨å®¡æ‰¹é€šè¿‡
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE);
        }
        // 3. æ ¡éªŒå¼€ç¥¨å·²ä¸Šä¼ å‘票附件
        if (CollUtil.isEmpty(storageAttachmentApi.listAttachments(
                StorageRecordTypeEnum.CRM_INVOICE.getType(), invoice.getId()))) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_ATTACHMENT);
        }
        // 4. æ ¡éªŒå¼€ç¥¨ä¸Žæ‰€é€‰åˆåŒä¸€è‡´
        if (ObjectUtil.notEqual(invoice.getContractId(), reqVO.getContractId())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH);
        }
        // 5. æ ¡éªŒå¼€ç¥¨ä¸Žæ‰€é€‰åˆåŒå®¢æˆ·ä¸€è‡´ï¼ˆé˜²å¾¡è„æ•°æ®ï¼šåˆåŒ-开票-回款一对多对多,开票必须属于该合同客户)
        if (reqVO.getCustomerId() != null && ObjectUtil.notEqual(invoice.getCustomerId(), reqVO.getCustomerId())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH);
        }
    }
    /**
     * æ ¡éªŒç´¯è®¡å›žæ¬¾é‡‘额不超过该开票剩余可回款金额(先开票后回款)
     */
    private void validateReceivablePriceExceedsInvoiceRemaining(CrmReceivableSaveReqVO reqVO) {
        // 1. è®¡ç®—该开票累计回款金额(不含本次,统计口径同合同金额校验:草稿 + å®¡æ‰¹ä¸­ + å®¡æ‰¹é€šè¿‡ï¼‰
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        List<CrmReceivableDO> receivables = receivableMapper.selectListByInvoiceIdAndStatus(reqVO.getInvoiceId(),
                Arrays.asList(CrmAuditStatusEnum.DRAFT.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus(),
                        CrmAuditStatusEnum.APPROVE.getStatus()));
        if (reqVO.getId() != null) {
            receivables.removeIf(receivable -> ObjectUtil.equal(receivable.getId(), reqVO.getId()));
        }
        BigDecimal receivablePriceSum = CollectionUtils.getSumValue(receivables, CrmReceivableDO::getPrice,
                BigDecimal::add, BigDecimal.ZERO);
        // 2. è®¡ç®—合同下「审批通过且已上传发票附件」的累计开票金额
        List<CrmInvoiceDO> invoices = invoiceService.getInvoiceListByContractIdAndStatus(reqVO.getContractId(),
                List.of(CrmAuditStatusEnum.APPROVE.getStatus()));
        BigDecimal invoicePriceSum = BigDecimal.ZERO;
        for (CrmInvoiceDO invoice : invoices) {
            if (CollUtil.isNotEmpty(storageAttachmentApi.listAttachments(
                    StorageRecordTypeEnum.CRM_INVOICE.getType(), invoice.getId()))) {
                invoicePriceSum = invoicePriceSum.add(invoice.getPrice());
            }
        }
        // 3. æ ¡éªŒæœ¬æ¬¡å›žæ¬¾é‡‘额不超过剩余可回款金额(已开票金额 - å·²å›žæ¬¾é‡‘额)
        BigDecimal availablePrice = invoicePriceSum.subtract(receivablePriceSum);
        // 2. æ ¡éªŒæœ¬æ¬¡å›žæ¬¾é‡‘额不超过该开票剩余可回款金额(开票金额 - å·²å›žæ¬¾é‡‘额)
        BigDecimal availablePrice = invoice.getPrice().subtract(receivablePriceSum);
        if (reqVO.getPrice().compareTo(availablePrice) > 0) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_ENOUGH, availablePrice);
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_PRICE_EXCEEDS, availablePrice);
        }
    }
@@ -201,17 +224,23 @@
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE)
    public void updateReceivable(CrmReceivableSaveReqVO updateReqVO) {
        Assert.notNull(updateReqVO.getId(), "回款编号不能为空");
        updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null).setPlanId(null); // ä¸å…è®¸ä¿®æ”¹çš„字段
        updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null).setPlanId(null); // ä¸å…è®¸ä¿®æ”¹çš„字段(invoiceId å…è®¸ä¿®æ”¹ï¼‰
        // 1.1 æ ¡éªŒå­˜åœ¨
        CrmReceivableDO oldReceivable = validateReceivableExists(updateReqVO.getId());
        updateReqVO.setOwnerUserId(oldReceivable.getOwnerUserId()).setCustomerId(oldReceivable.getCustomerId())
                .setContractId(oldReceivable.getContractId()).setPlanId(oldReceivable.getPlanId()); // è®¾ç½®å·²å­˜åœ¨çš„值
        // 1.2 æ ¡éªŒå¯å›žæ¬¾é‡‘额超过上限
        // 1.2 è‹¥æœªä¼ é€’关联开票,沿用旧值
        if (updateReqVO.getInvoiceId() == null) {
            updateReqVO.setInvoiceId(oldReceivable.getInvoiceId());
        }
        // 1.3 æ ¡éªŒå¯å›žæ¬¾é‡‘额不超过合同剩余可回款金额
        validateReceivablePriceExceedsLimit(updateReqVO);
        // 1.3 æ ¡éªŒå¯å›žæ¬¾é‡‘额不超过累计已开票金额(先开票后回款)
        validateReceivablePriceExceedsInvoiceLimit(updateReqVO);
        // 1.4 æ ¡éªŒå…³è”开票合格
        validateInvoiceForReceivable(updateReqVO);
        // 1.5 æ ¡éªŒå¯å›žæ¬¾é‡‘额不超过该开票剩余可回款金额
        validateReceivablePriceExceedsInvoiceRemaining(updateReqVO);
        // 1.4 åªæœ‰è‰ç¨¿ã€å®¡æ‰¹ä¸­ï¼Œå¯ä»¥ç¼–辑;
        // 1.6 åªæœ‰è‰ç¨¿ã€å®¡æ‰¹ä¸­ï¼Œå¯ä»¥ç¼–辑;
        if (!ObjectUtils.equalsAny(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(),
                CrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED);
@@ -346,6 +375,11 @@
    }
    @Override
    public Map<Long, BigDecimal> getReceivablePriceMapByInvoiceId(Collection<Long> invoiceIds) {
        return receivableMapper.selectReceivablePriceMapByInvoiceId(invoiceIds);
    }
    @Override
    public Long getReceivableCountByContractId(Long contractId) {
        return receivableMapper.selectCountByContractId(contractId);
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpFinancePaymentController.java
@@ -12,13 +12,16 @@
import cn.iocoder.yudao.module.erp.controller.admin.finance.vo.payment.ErpFinancePaymentPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.finance.vo.payment.ErpFinancePaymentRespVO;
import cn.iocoder.yudao.module.erp.controller.admin.finance.vo.payment.ErpFinancePaymentSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpAccountDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentItemDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.erp.service.finance.ErpAccountService;
import cn.iocoder.yudao.module.erp.service.finance.ErpFinancePaymentService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceService;
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;
@@ -53,6 +56,8 @@
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private ErpAccountService accountService;
    @Resource
    private ErpPurchaseInvoiceService invoiceService;
    @Resource
    private AdminUserApi adminUserApi;
@@ -102,9 +107,23 @@
            return success(null);
        }
        List<ErpFinancePaymentItemDO> paymentItemList = financePaymentService.getFinancePaymentItemListByPaymentId(id);
        return success(BeanUtils.toBean(payment, ErpFinancePaymentRespVO.class, financePaymentVO ->
                financePaymentVO.setItems(BeanUtils.toBean(paymentItemList, ErpFinancePaymentRespVO.Item.class))
                .setAttachmentList(storageAttachmentApi.listAttachments("erp_finance_payment", id))));
        return success(buildFinancePaymentVO(payment, paymentItemList));
    }
    private ErpFinancePaymentRespVO buildFinancePaymentVO(ErpFinancePaymentDO payment,
                                                          List<ErpFinancePaymentItemDO> paymentItemList) {
        ErpPurchaseInvoiceRespVO invoiceVO = null;
        if (payment.getInvoiceId() != null) {
            ErpPurchaseInvoiceDO invoice = invoiceService.getPurchaseInvoice(payment.getInvoiceId());
            if (invoice != null) {
                invoiceVO = BeanUtils.toBean(invoice, ErpPurchaseInvoiceRespVO.class);
            }
        }
        ErpPurchaseInvoiceRespVO invoiceFinal = invoiceVO;
        return BeanUtils.toBean(payment, ErpFinancePaymentRespVO.class, financePaymentVO ->
                financePaymentVO.setInvoice(invoiceFinal)
                .setItems(BeanUtils.toBean(paymentItemList, ErpFinancePaymentRespVO.Item.class))
                .setAttachmentList(storageAttachmentApi.listAttachments("erp_finance_payment", payment.getId())));
    }
    @GetMapping("/page")
@@ -145,6 +164,9 @@
        // 1.4 ç®¡ç†å‘˜ä¿¡æ¯
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(convertListByFlatMap(pageResult.getList(),
                contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getFinanceUserId())));
        // 1.5 æ¥ç¥¨ä¿¡æ¯
        Map<Long, ErpPurchaseInvoiceDO> invoiceMap = invoiceService.getPurchaseInvoiceMap(
                convertSet(pageResult.getList(), ErpFinancePaymentDO::getInvoiceId));
        // 2. å¼€å§‹æ‹¼æŽ¥
        return BeanUtils.toBean(pageResult, ErpFinancePaymentRespVO.class, payment -> {
            payment.setItems(BeanUtils.toBean(financePaymentItemMap.get(payment.getId()), ErpFinancePaymentRespVO.Item.class));
@@ -152,6 +174,8 @@
            MapUtils.findAndThen(accountMap, payment.getAccountId(), account -> payment.setAccountName(account.getName()));
            MapUtils.findAndThen(userMap, Long.parseLong(payment.getCreator()), user -> payment.setCreatorName(user.getNickname()));
            MapUtils.findAndThen(userMap, payment.getFinanceUserId(), user -> payment.setFinanceUserName(user.getNickname()));
            MapUtils.findAndThen(invoiceMap, payment.getInvoiceId(), invoice ->
                    payment.setInvoice(BeanUtils.toBean(invoice, ErpPurchaseInvoiceRespVO.class)));
        });
    }
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentRespVO.java
@@ -2,6 +2,7 @@
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceRespVO;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@@ -38,6 +39,11 @@
    @Schema(description = "供应商名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "小番茄公司")
    private String supplierName;
    @Schema(description = "关联的来票编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25787")
    private Long invoiceId;
    @Schema(description = "关联的来票信息")
    private ErpPurchaseInvoiceRespVO invoice;
    @Schema(description = "付款账户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "28989")
    private Long accountId;
    @Schema(description = "付款账户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentSaveReqVO.java
@@ -28,6 +28,10 @@
    @NotNull(message = "供应商编号不能为空")
    private Long supplierId;
    @Schema(description = "关联的来票编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25787")
    @NotNull(message = "关联来票编号不能为空")
    private Long invoiceId;
    @Schema(description = "付款账户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "28989")
    @NotNull(message = "付款账户编号不能为空")
    private Long accountId;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,234 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
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.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;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderRespVO;
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.enums.ErpPurchaseInvoiceAuditStatusEnum;
import cn.iocoder.yudao.module.erp.service.finance.ErpFinancePaymentService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceServiceImpl;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseOrderService;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
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 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 jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - ERP é‡‡è´­æ¥ç¥¨")
@RestController
@RequestMapping("/erp/purchase-invoice")
@Validated
public class ErpPurchaseInvoiceController {
    @Resource
    private ErpPurchaseInvoiceService invoiceService;
    @Resource
    private ErpPurchaseOrderService purchaseOrderService;
    @Resource
    private ErpFinancePaymentService financePaymentService;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建来票")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:create')")
    public CommonResult<Long> createPurchaseInvoice(@Valid @RequestBody ErpPurchaseInvoiceSaveReqVO createReqVO) {
        return success(invoiceService.createPurchaseInvoice(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新来票")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:update')")
    public CommonResult<Boolean> updatePurchaseInvoice(@Valid @RequestBody ErpPurchaseInvoiceSaveReqVO updateReqVO) {
        invoiceService.updatePurchaseInvoice(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除来票")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:delete')")
    public CommonResult<Boolean> deletePurchaseInvoice(@RequestParam("id") Long id) {
        invoiceService.deletePurchaseInvoice(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得来票")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:query')")
    public CommonResult<ErpPurchaseInvoiceRespVO> getPurchaseInvoice(@RequestParam("id") Long id) {
        ErpPurchaseInvoiceDO invoice = invoiceService.getPurchaseInvoice(id);
        return success(buildInvoiceDetail(invoice));
    }
    private ErpPurchaseInvoiceRespVO buildInvoiceDetail(ErpPurchaseInvoiceDO invoice) {
        if (invoice == null) {
            return null;
        }
        return buildInvoiceDetailList(Collections.singletonList(invoice)).get(0);
    }
    @GetMapping("/page")
    @Operation(summary = "获得来票分页")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:query')")
    public CommonResult<PageResult<ErpPurchaseInvoiceRespVO>> getPurchaseInvoicePage(@Valid ErpPurchaseInvoicePageReqVO pageReqVO) {
        PageResult<ErpPurchaseInvoiceDO> pageResult = invoiceService.getPurchaseInvoicePage(pageReqVO);
        return success(new PageResult<>(buildInvoiceDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出来票 Excel")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportPurchaseInvoiceExcel(@Valid ErpPurchaseInvoicePageReqVO pageReqVO,
                                           HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PAGE_SIZE_NONE);
        List<ErpPurchaseInvoiceDO> list = invoiceService.getPurchaseInvoicePage(pageReqVO).getList();
        // å¯¼å‡º Excel
        ExcelUtils.write(response, "来票.xls", "数据", ErpPurchaseInvoiceRespVO.class,
                buildInvoiceDetailList(list));
    }
    private List<ErpPurchaseInvoiceRespVO> buildInvoiceDetailList(List<ErpPurchaseInvoiceDO> invoiceList) {
        if (CollUtil.isEmpty(invoiceList)) {
            return Collections.emptyList();
        }
        // 1.1 èŽ·å–ä¾›åº”å•†åˆ—è¡¨
        Map<Long, SrmSupplierRespDTO> supplierMap = srmSupplierApi.getSupplierList(
                convertSet(invoiceList, ErpPurchaseInvoiceDO::getSupplierId)).getCheckedData().stream()
                .collect(java.util.stream.Collectors.toMap(SrmSupplierRespDTO::getId, v -> v, (a, b) -> a));
        // 1.2 èŽ·å–é‡‡è´­è®¢å•åˆ—è¡¨
        Map<Long, ErpPurchaseOrderDO> orderMap = convertMap(
                purchaseOrderService.getPurchaseOrderList(convertList(invoiceList, ErpPurchaseInvoiceDO::getPurchaseOrderId)),
                ErpPurchaseOrderDO::getId);
        // 1.3 èŽ·å–åˆ›å»ºäººåˆ—è¡¨
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(invoiceList, invoice -> NumberUtils.parseLong(invoice.getCreator())));
        // 1.4 èŽ·å¾—æ¯å¼ æ¥ç¥¨å·²ä»˜æ¬¾é‡‘é¢ï¼ˆæœªå®¡æ ¸ + å®¡æ‰¹ä¸­ + å·²å®¡æ ¸ï¼‰
        Map<Long, BigDecimal> paymentPriceMap = financePaymentService.getPaymentPriceMapByInvoiceId(
                convertSet(invoiceList, ErpPurchaseInvoiceDO::getId));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(invoiceList, ErpPurchaseInvoiceRespVO.class, (invoiceVO) -> {
            // 2.1 æ‹¼æŽ¥ä¾›åº”商名称
            findAndThen(supplierMap, invoiceVO.getSupplierId(), supplier -> invoiceVO.setSupplierName(supplier.getName()));
            // 2.2 æ‹¼æŽ¥é‡‡è´­è®¢å•信息
            findAndThen(orderMap, invoiceVO.getPurchaseOrderId(), order ->
                    invoiceVO.setOrder(BeanUtils.toBean(order, ErpPurchaseOrderRespVO.class)));
            // 2.3 æ‹¼æŽ¥åˆ›å»ºäººåç§°
            MapUtils.findAndThen(userMap, NumberUtils.parseLong(invoiceVO.getCreator()),
                    user -> invoiceVO.setCreatorName(user.getNickname()));
            // 2.4 æ‹¼æŽ¥å‘票附件 + æ˜¯å¦å·²ä¸Šä¼ é™„ä»¶
            invoiceVO.setAttachmentList(storageAttachmentApi.listAttachments(
                    ErpPurchaseInvoiceServiceImpl.PURCHASE_INVOICE_RECORD_TYPE, invoiceVO.getId()));
            invoiceVO.setHasAttachment(CollUtil.isNotEmpty(invoiceVO.getAttachmentList()));
            // 2.5 æ‹¼æŽ¥å‰©ä½™å¯ä»˜é‡‘额
            invoiceVO.setRemainingInvoicePrice(invoiceVO.getPrice().subtract(
                    paymentPriceMap.getOrDefault(invoiceVO.getId(), BigDecimal.ZERO)));
        });
    }
    @PutMapping("/submit")
    @Operation(summary = "提交来票审批")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:update')")
    public CommonResult<Boolean> submitPurchaseInvoice(@RequestParam("id") Long id) {
        invoiceService.submitPurchaseInvoice(id, getLoginUserId());
        return success(true);
    }
    @GetMapping("/audit-count")
    @Operation(summary = "获得待审核来票数量")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:query')")
    public CommonResult<Long> getAuditInvoiceCount() {
        return success(invoiceService.getAuditInvoiceCount());
    }
    @GetMapping("/simple-list")
    @Operation(summary = "获得来票精简列表", description = "只包含审核通过且已上传发票附件的来票,主要用于付款创建时选择关联来票")
    @Parameter(name = "supplierId", description = "供应商编号", required = true)
    @Parameter(name = "purchaseOrderId", description = "采购订单编号(可选,用于按订单过滤)")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:query')")
    public CommonResult<List<ErpPurchaseInvoiceRespVO>> getInvoiceSimpleList(
            @RequestParam("supplierId") Long supplierId,
            @RequestParam(value = "purchaseOrderId", required = false) Long purchaseOrderId) {
        List<ErpPurchaseInvoiceDO> invoices;
        if (purchaseOrderId != null) {
            invoices = invoiceService.getPurchaseInvoiceListByOrderIdAndStatus(purchaseOrderId,
                    List.of(ErpPurchaseInvoiceAuditStatusEnum.APPROVE.getStatus()));
        } else {
            invoices = invoiceService.getPurchaseInvoiceListBySupplierIdAndStatus(supplierId,
                    List.of(ErpPurchaseInvoiceAuditStatusEnum.APPROVE.getStatus()));
        }
        if (CollUtil.isEmpty(invoices)) {
            return success(Collections.emptyList());
        }
        // è¿‡æ»¤ï¼šå·²ä¸Šä¼ å‘票附件
        invoices.removeIf(invoice -> CollUtil.isEmpty(storageAttachmentApi.listAttachments(
                ErpPurchaseInvoiceServiceImpl.PURCHASE_INVOICE_RECORD_TYPE, invoice.getId())));
        if (CollUtil.isEmpty(invoices)) {
            return success(Collections.emptyList());
        }
        // è®¡ç®—每张来票已付款金额(未审核 + å®¡æ‰¹ä¸­ + å·²å®¡æ ¸ï¼Œå£å¾„与创建付款校验一致)
        Map<Long, BigDecimal> paymentPriceMap = financePaymentService.getPaymentPriceMapByInvoiceId(
                convertSet(invoices, ErpPurchaseInvoiceDO::getId));
        // è¿‡æ»¤ï¼šå‰©ä½™å¯ä»˜é‡‘额 > 0 æ‰å¯é€‰
        List<ErpPurchaseInvoiceRespVO> result = new ArrayList<>();
        for (ErpPurchaseInvoiceDO invoice : invoices) {
            BigDecimal remainingPrice = invoice.getPrice()
                    .subtract(paymentPriceMap.getOrDefault(invoice.getId(), BigDecimal.ZERO));
            if (remainingPrice.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            result.add(new ErpPurchaseInvoiceRespVO() // åªè¿”回精简字段
                    .setId(invoice.getId()).setNo(invoice.getNo()).setInvoiceNo(invoice.getInvoiceNo())
                    .setInvoiceTitle(invoice.getInvoiceTitle()).setPrice(invoice.getPrice())
                    .setSupplierId(invoice.getSupplierId()).setPurchaseOrderId(invoice.getPurchaseOrderId())
                    .setAuditStatus(invoice.getAuditStatus()).setHasAttachment(true)
                    .setRemainingInvoicePrice(remainingPrice));
        }
        return success(result);
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoicePageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseInvoiceAuditStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@Schema(description = "管理后台 - ERP é‡‡è´­æ¥ç¥¨åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class ErpPurchaseInvoicePageReqVO extends PageParam {
    @Schema(description = "来票编号")
    private String no;
    @Schema(description = "供应商编号", example = "29399")
    private Long supplierId;
    @Schema(description = "采购订单编号", example = "17386")
    private Long purchaseOrderId;
    @Schema(description = "审批状态", example = "20")
    @InEnum(ErpPurchaseInvoiceAuditStatusEnum.class)
    private Integer auditStatus;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderRespVO;
import cn.iocoder.yudao.module.erp.enums.DictTypeConstants;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - ERP é‡‡è´­æ¥ç¥¨ Response VO")
@Data
@ExcelIgnoreUnannotated
public class ErpPurchaseInvoiceRespVO {
    @Schema(description = "编号", example = "25787")
    @ExcelProperty("编号")
    private Long id;
    @Schema(description = "来票编号", example = "FPD888")
    @ExcelProperty("来票编号")
    private String no;
    @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "29399")
    private Long supplierId;
    @Schema(description = "供应商名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "小番茄公司")
    @ExcelProperty("供应商名称")
    private String supplierName;
    @Schema(description = "采购订单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17386")
    @ExcelProperty("采购订单编号")
    private Long purchaseOrderId;
    @Schema(description = "采购订单信息")
    private ErpPurchaseOrderRespVO order;
    @Schema(description = "发票号码", example = "02564178")
    @ExcelProperty("发票号码")
    private String invoiceNo;
    @Schema(description = "发票抬头", example = "XX科技有限公司")
    @ExcelProperty("发票抬头")
    private String invoiceTitle;
    @Schema(description = "来票金额,单位:元", requiredMode = Schema.RequiredMode.REQUIRED, example = "9000")
    @ExcelProperty("来票金额")
    private BigDecimal price;
    @Schema(description = "来票日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-02")
    @ExcelProperty("来票日期")
    private LocalDateTime invoiceTime;
    @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    @ExcelProperty(value = "审批状态", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.AUDIT_STATUS)
    private Integer auditStatus;
    @Schema(description = "工作流编号", example = "1043")
    @ExcelProperty("工作流编号")
    private String processInstanceId;
    @Schema(description = "备注", example = "备注")
    @ExcelProperty("备注")
    private String remark;
    @Schema(description = "发票附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "是否已上传发票附件", example = "true")
    private Boolean hasAttachment;
    @Schema(description = "剩余可付金额,等于 æ¥ç¥¨é‡‘额 - è¯¥æ¥ç¥¨å·²ä»˜æ¬¾é‡‘额(未审核+审批中+已审核)", example = "1000.00")
    private BigDecimal remainingInvoicePrice;
    @Schema(description = "创建人", example = "25682")
    private String creator;
    @Schema(description = "创建人名称", example = "test")
    @ExcelProperty("创建人名称")
    private String creatorName;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
    @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("更新时间")
    private LocalDateTime updateTime;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - ERP é‡‡è´­æ¥ç¥¨æ–°å¢ž/修改 Request VO")
@Data
public class ErpPurchaseInvoiceSaveReqVO {
    @Schema(description = "编号", example = "25787")
    private Long id;
    @Schema(description = "供应商编号", example = "29399")
    private Long supplierId; // è¯¥å­—段不通过前端传递,而是 purchaseOrderId æŸ¥è¯¢å‡ºæ¥è®¾ç½®è¿›åŽ»
    @Schema(description = "采购订单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17386")
    @NotNull(message = "采购订单编号不能为空")
    private Long purchaseOrderId;
    @Schema(description = "发票号码", example = "02564178")
    private String invoiceNo;
    @Schema(description = "发票抬头", example = "XX科技有限公司")
    private String invoiceTitle;
    @Schema(description = "来票金额,单位:元", requiredMode = Schema.RequiredMode.REQUIRED, example = "9000")
    @NotNull(message = "来票金额不能为空")
    private BigDecimal price;
    @Schema(description = "来票日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-02")
    @NotNull(message = "来票日期不能为空")
    private LocalDateTime invoiceTime;
    @Schema(description = "备注", example = "备注")
    private String remark;
    @Schema(description = "发票附件 BlobId åˆ—表", example = "[1, 2, 3]")
    private List<Long> blobIds;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpFinancePaymentDO.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.erp.dal.dataobject.finance;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpSupplierDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -57,6 +58,12 @@
     */
    private Long supplierId;
    /**
     * å…³è”的来票编号
     *
     * å…³è” {@link ErpPurchaseInvoiceDO#getId()}
     */
    private Long invoiceId;
    /**
     * ä»˜æ¬¾è´¦æˆ·ç¼–号
     *
     * å…³è” {@link ErpAccountDO#getId()}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
package cn.iocoder.yudao.module.erp.dal.dataobject.purchase;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseInvoiceAuditStatusEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * ERP é‡‡è´­æ¥ç¥¨ DO
 *
 * @author è¶…级管理员
 */
@TableName("erp_purchase_invoice")
@KeySequence("erp_purchase_invoice_seq") // ç”¨äºŽ Oracle、PostgreSQL、Kingbase、DB2、H2 æ•°æ®åº“的主键自增。如果是 MySQL ç­‰æ•°æ®åº“,可不写。
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErpPurchaseInvoiceDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æ¥ç¥¨ç¼–号
     */
    private String no;
    /**
     * ä¾›åº”商编号
     *
     * å…³è” {@link ErpSupplierDO#getId()}
     */
    private Long supplierId;
    /**
     * é‡‡è´­è®¢å•编号
     *
     * å…³è” {@link ErpPurchaseOrderDO#getId()}
     */
    private Long purchaseOrderId;
    /**
     * å‘票号码
     */
    private String invoiceNo;
    /**
     * å‘票抬头
     */
    private String invoiceTitle;
    /**
     * æ¥ç¥¨é‡‘额,单位:元
     */
    private BigDecimal price;
    /**
     * æ¥ç¥¨æ—¥æœŸ
     */
    private LocalDateTime invoiceTime;
    /**
     * å¤‡æ³¨
     */
    private String remark;
    /**
     * å·¥ä½œæµç¼–号
     *
     * å…³è” ProcessInstance çš„ id å±žæ€§
     */
    private String processInstanceId;
    /**
     * å®¡æ‰¹çŠ¶æ€
     *
     * æžšä¸¾ {@link ErpPurchaseInvoiceAuditStatusEnum}
     */
    private Integer auditStatus;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java
@@ -1,13 +1,25 @@
package cn.iocoder.yudao.module.erp.dal.mysql.finance;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
import cn.iocoder.yudao.module.erp.controller.admin.finance.vo.payment.ErpFinancePaymentPageReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentItemDO;
import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
/**
 * ERP ä»˜æ¬¾å• Mapper
@@ -45,4 +57,31 @@
        return selectOne(ErpFinancePaymentDO::getNo, no);
    }
    default List<ErpFinancePaymentDO> selectListByInvoiceIdAndStatus(Long invoiceId, Collection<Integer> statuses) {
        return selectList(new LambdaQueryWrapperX<ErpFinancePaymentDO>()
                .eq(ErpFinancePaymentDO::getInvoiceId, invoiceId)
                .in(ErpFinancePaymentDO::getStatus, statuses));
    }
    /**
     * ç»Ÿè®¡æ¯å¼ æ¥ç¥¨å·²ä»˜æ¬¾é‡‘额(未审核 + å®¡æ‰¹ä¸­ + å·²å®¡æ ¸ï¼ŒæŽ’除作废),用于校验剩余可付金额
     *
     * @param invoiceIds æ¥ç¥¨ç¼–号列表
     * @return key ä¸ºæ¥ç¥¨ç¼–号,value ä¸ºå·²ä»˜æ¬¾é‡‘额
     */
    default Map<Long, BigDecimal> selectPaymentPriceMapByInvoiceId(Collection<Long> invoiceIds) {
        if (CollUtil.isEmpty(invoiceIds)) {
            return Collections.emptyMap();
        }
        // SQL sum æŸ¥è¯¢
        List<Map<String, Object>> result = selectMaps(new QueryWrapper<ErpFinancePaymentDO>()
                .select("invoice_id, SUM(payment_price) AS total_payment_price")
                .in("status", ErpAuditStatus.PROCESS.getStatus(), // æœªå®¡æ ¸ + å®¡æ‰¹ä¸­ + å·²å®¡æ ¸
                        ErpAuditStatus.APPROVING.getStatus(), ErpAuditStatus.APPROVE.getStatus())
                .groupBy("invoice_id")
                .in("invoice_id", invoiceIds));
        // èŽ·å¾—é‡‘é¢
        return convertMap(result, obj -> (Long) obj.get("invoice_id"), obj -> (BigDecimal) obj.get("total_payment_price"));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.erp.dal.mysql.purchase;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoicePageReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseInvoiceAuditStatusEnum;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
 * ERP é‡‡è´­æ¥ç¥¨ Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface ErpPurchaseInvoiceMapper extends BaseMapperX<ErpPurchaseInvoiceDO> {
    default ErpPurchaseInvoiceDO selectByNo(String no) {
        return selectOne(ErpPurchaseInvoiceDO::getNo, no);
    }
    default PageResult<ErpPurchaseInvoiceDO> selectPage(ErpPurchaseInvoicePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .likeIfPresent(ErpPurchaseInvoiceDO::getNo, reqVO.getNo())
                .eqIfPresent(ErpPurchaseInvoiceDO::getSupplierId, reqVO.getSupplierId())
                .eqIfPresent(ErpPurchaseInvoiceDO::getPurchaseOrderId, reqVO.getPurchaseOrderId())
                .eqIfPresent(ErpPurchaseInvoiceDO::getAuditStatus, reqVO.getAuditStatus())
                .orderByDesc(ErpPurchaseInvoiceDO::getId));
    }
    default List<ErpPurchaseInvoiceDO> selectListByOrderIdAndStatus(Long orderId, Collection<Integer> auditStatuses) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .eq(ErpPurchaseInvoiceDO::getPurchaseOrderId, orderId)
                .in(ErpPurchaseInvoiceDO::getAuditStatus, auditStatuses));
    }
    default List<ErpPurchaseInvoiceDO> selectListBySupplierIdAndStatus(Long supplierId, Collection<Integer> auditStatuses) {
        return selectList(new LambdaQueryWrapperX<ErpPurchaseInvoiceDO>()
                .eq(ErpPurchaseInvoiceDO::getSupplierId, supplierId)
                .in(ErpPurchaseInvoiceDO::getAuditStatus, auditStatuses));
    }
    default Long selectCountByAudit() {
        return selectCount(ErpPurchaseInvoiceDO::getAuditStatus, ErpPurchaseInvoiceAuditStatusEnum.PROCESS.getStatus());
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java
@@ -35,6 +35,11 @@
    public static final String PURCHASE_REQUEST_NO_PREFIX = "CGSQ";
    /**
     * é‡‡è´­æ¥ç¥¨ {@link cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO}
     */
    public static final String PURCHASE_INVOICE_NO_PREFIX = "FPD";
    /**
     * ä»˜æ¬¾å• {@link cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentDO}
     */
    public static final String FINANCE_PAYMENT_NO_PREFIX = "FKD";
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseInvoiceAuditStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.erp.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * ERP é‡‡è´­æ¥ç¥¨å®¡æ‰¹çŠ¶æ€æžšä¸¾
 *
 * @author è¶…级管理员
 */
@RequiredArgsConstructor
@Getter
public enum ErpPurchaseInvoiceAuditStatusEnum implements ArrayValuable<Integer> {
    DRAFT(0, "未提交"),
    PROCESS(10, "审批中"),
    APPROVE(20, "审核通过"),
    REJECT(30, "审核不通过"),
    CANCEL(40, "已取消");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(ErpPurchaseInvoiceAuditStatusEnum::getStatus).toArray(Integer[]::new);
    private final Integer status;
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
@@ -54,6 +54,17 @@
    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, "采购申请审批分类下没有可用的流程模型,请先创建并发布流程");
    // ========== ERP é‡‡è´­æ¥ç¥¨ï¼ˆ1-030-105-000) ==========
    ErrorCode PURCHASE_INVOICE_NOT_EXISTS = new ErrorCode(1_030_105_000, "来票不存在");
    ErrorCode PURCHASE_INVOICE_NO_EXISTS = new ErrorCode(1_030_105_001, "生成来票编号失败,请重新提交");
    ErrorCode PURCHASE_INVOICE_UPDATE_FAIL_EDITING_PROHIBITED = new ErrorCode(1_030_105_002, "来票({})不是草稿状态,无法修改");
    ErrorCode PURCHASE_INVOICE_DELETE_FAIL_IS_APPROVE = new ErrorCode(1_030_105_003, "删除来票失败,原因:来票审批已通过");
    ErrorCode PURCHASE_INVOICE_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_030_105_004, "来票提交审核失败,原因:来票没处在未提交状态");
    ErrorCode PURCHASE_INVOICE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_030_105_005, "更新来票审核状态失败,原因:来票不是审核中状态");
    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, "创建来票失败,原因:来票金额超出采购订单金额,目前可开票:{} å…ƒ");
    // ========== ERP é‡‡è´­é€€è´§ï¼ˆ1-030-103-000) ==========
    ErrorCode PURCHASE_RETURN_NOT_EXISTS = new ErrorCode(1_030_103_000, "采购退货单不存在");
    ErrorCode PURCHASE_RETURN_DELETE_FAIL_APPROVE = new ErrorCode(1_030_103_001, "采购退货单({})已审核,无法删除");
@@ -180,6 +191,11 @@
    ErrorCode FINANCE_PAYMENT_APPROVE_FAIL = new ErrorCode(1_030_601_003, "审核失败,只有未审核的付款单才能审核");
    ErrorCode FINANCE_PAYMENT_NO_EXISTS = new ErrorCode(1_030_601_004, "生成付款单号失败,请重新提交");
    ErrorCode FINANCE_PAYMENT_UPDATE_FAIL_APPROVE = new ErrorCode(1_030_601_005, "付款单({})已审核,无法修改");
    ErrorCode FINANCE_PAYMENT_CREATE_FAIL_INVOICE_NOT_EXISTS = new ErrorCode(1_030_601_006, "创建付款单失败,原因:关联的来票不存在");
    ErrorCode FINANCE_PAYMENT_CREATE_FAIL_INVOICE_NOT_APPROVE = new ErrorCode(1_030_601_007, "创建付款单失败,原因:关联的来票不是审批通过状态");
    ErrorCode FINANCE_PAYMENT_CREATE_FAIL_INVOICE_NOT_ATTACHMENT = new ErrorCode(1_030_601_008, "创建付款单失败,原因:关联的来票未上传发票附件");
    ErrorCode FINANCE_PAYMENT_CREATE_FAIL_INVOICE_SUPPLIER_MISMATCH = new ErrorCode(1_030_601_009, "创建付款单失败,原因:关联的来票与所选供应商不匹配");
    ErrorCode FINANCE_PAYMENT_CREATE_FAIL_INVOICE_PRICE_EXCEEDS = new ErrorCode(1_030_601_010, "创建付款单失败,原因:付款金额超出该来票剩余可付金额,目前可付:{} å…ƒ");
    // ========== ERP æ”¶æ¬¾å• 1-030-602-000 ==========
    ErrorCode FINANCE_RECEIPT_NOT_EXISTS = new ErrorCode(1_030_602_000, "收款单不存在");
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/common/ErpBizTypeEnum.java
@@ -18,6 +18,7 @@
    PURCHASE_ORDER(10, "采购订单"),
    PURCHASE_IN(11, "采购入库"),
    PURCHASE_RETURN(12, "采购退货"),
    PURCHASE_INVOICE(13, "采购来票"),
    SALE_ORDER(20, "销售订单"),
    SALE_OUT(21, "销售出库"),
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentService.java
@@ -7,8 +7,10 @@
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentItemDO;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
 * ERP ä»˜æ¬¾å• Service æŽ¥å£
@@ -63,6 +65,14 @@
     */
    PageResult<ErpFinancePaymentDO> getFinancePaymentPage(ErpFinancePaymentPageReqVO pageReqVO);
    /**
     * ç»Ÿè®¡æ¯å¼ æ¥ç¥¨å·²ä»˜æ¬¾é‡‘额(未审核 + å®¡æ‰¹ä¸­ + å·²å®¡æ ¸ï¼ŒæŽ’除作废)
     *
     * @param invoiceIds æ¥ç¥¨ç¼–号列表
     * @return key ä¸ºæ¥ç¥¨ç¼–号,value ä¸ºå·²ä»˜æ¬¾é‡‘额
     */
    Map<Long, BigDecimal> getPaymentPriceMapByInvoiceId(Collection<Long> invoiceIds);
    // ==================== ä»˜æ¬¾å•项 ====================
    /**
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentServiceImpl.java
@@ -1,16 +1,21 @@
package cn.iocoder.yudao.module.erp.service.finance;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
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.finance.vo.payment.ErpFinancePaymentPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.finance.vo.payment.ErpFinancePaymentSaveReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentItemDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import cn.iocoder.yudao.module.erp.dal.mysql.finance.ErpFinancePaymentItemMapper;
import cn.iocoder.yudao.module.erp.dal.mysql.finance.ErpFinancePaymentMapper;
import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO;
import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseInvoiceAuditStatusEnum;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceServiceImpl;
import cn.iocoder.yudao.module.erp.service.purchase.ErpSupplierService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
@@ -23,6 +28,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@@ -49,6 +55,8 @@
    private ErpSupplierService supplierService;
    @Resource
    private ErpAccountService accountService;
    @Resource
    private ErpPurchaseInvoiceService invoiceService;
    @Resource
    private AdminUserApi adminUserApi;
@@ -72,7 +80,13 @@
        if (createReqVO.getFinanceUserId() != null) {
            adminUserApi.validateUser(createReqVO.getFinanceUserId());
        }
        // 1.5 ç”Ÿæˆä»˜æ¬¾å•号,并校验唯一性
        // 1.5 æ ¡éªŒå…³è”来票
        validateInvoiceForPayment(createReqVO.getInvoiceId(), createReqVO.getSupplierId());
        // 1.6 æ ¡éªŒä»˜æ¬¾é‡‘额不超过该来票剩余可付金额
        validatePaymentPriceExceedsInvoiceRemaining(createReqVO.getInvoiceId(),
                getSumValue(paymentItems, ErpFinancePaymentItemDO::getPaymentPrice, BigDecimal::add, BigDecimal.ZERO)
                        .subtract(createReqVO.getDiscountPrice()), null);
        // 1.7 ç”Ÿæˆä»˜æ¬¾å•号,并校验唯一性
        String no = noRedisDAO.generate(ErpNoRedisDAO.FINANCE_PAYMENT_NO_PREFIX);
        if (financePaymentMapper.selectByNo(no) != null) {
            throw exception(FINANCE_PAYMENT_NO_EXISTS);
@@ -117,6 +131,12 @@
        // 1.5 æ ¡éªŒä»˜æ¬¾å•项的有效性
        List<ErpFinancePaymentItemDO> paymentItems = validateFinancePaymentItems(
                updateReqVO.getSupplierId(), updateReqVO.getItems());
        // 1.6 æ ¡éªŒå…³è”来票
        validateInvoiceForPayment(updateReqVO.getInvoiceId(), updateReqVO.getSupplierId());
        // 1.7 æ ¡éªŒä»˜æ¬¾é‡‘额不超过该来票剩余可付金额(排除该付款单自身当前已付金额)
        validatePaymentPriceExceedsInvoiceRemaining(updateReqVO.getInvoiceId(),
                getSumValue(paymentItems, ErpFinancePaymentItemDO::getPaymentPrice, BigDecimal::add, BigDecimal.ZERO)
                        .subtract(updateReqVO.getDiscountPrice()), payment.getPaymentPrice());
        // 2.1 æ›´æ–°ä»˜æ¬¾å•
        ErpFinancePaymentDO updateObj = BeanUtils.toBean(updateReqVO, ErpFinancePaymentDO.class);
@@ -224,6 +244,53 @@
        return payment;
    }
    /**
     * æ ¡éªŒä»˜æ¬¾å•关联的来票:存在、审批通过、已上传发票附件、供应商与来票一致
     *
     * @param invoiceId æ¥ç¥¨ç¼–号
     * @param supplierId ä¾›åº”商编号
     */
    private void validateInvoiceForPayment(Long invoiceId, Long supplierId) {
        // 1. æ ¡éªŒæ¥ç¥¨å­˜åœ¨
        ErpPurchaseInvoiceDO invoice = invoiceService.getPurchaseInvoice(invoiceId);
        if (invoice == null) {
            throw exception(FINANCE_PAYMENT_CREATE_FAIL_INVOICE_NOT_EXISTS);
        }
        // 2. æ ¡éªŒæ¥ç¥¨å®¡æ‰¹é€šè¿‡
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), ErpPurchaseInvoiceAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(FINANCE_PAYMENT_CREATE_FAIL_INVOICE_NOT_APPROVE);
        }
        // 3. æ ¡éªŒå·²ä¸Šä¼ å‘票附件
        if (CollUtil.isEmpty(storageAttachmentApi.listAttachments(
                ErpPurchaseInvoiceServiceImpl.PURCHASE_INVOICE_RECORD_TYPE, invoiceId))) {
            throw exception(FINANCE_PAYMENT_CREATE_FAIL_INVOICE_NOT_ATTACHMENT);
        }
        // 4. æ ¡éªŒä¾›åº”商与来票一致(防脏数据)
        if (ObjectUtil.notEqual(invoice.getSupplierId(), supplierId)) {
            throw exception(FINANCE_PAYMENT_CREATE_FAIL_INVOICE_SUPPLIER_MISMATCH);
        }
    }
    /**
     * æ ¡éªŒä»˜æ¬¾é‡‘额不超过该来票剩余可付金额(来票金额 - è¯¥æ¥ç¥¨å·²ä»˜æ¬¾é‡‘额)
     *
     * @param invoiceId æ¥ç¥¨ç¼–号
     * @param paymentPrice æœ¬æ¬¡ä»˜æ¬¾é‡‘额
     * @param oldPaymentPrice æ›´æ–°åœºæ™¯ä¸‹ï¼Œè¯¥ä»˜æ¬¾å•自身当前的付款金额(用于排除自身),创建场景传 null
     */
    private void validatePaymentPriceExceedsInvoiceRemaining(Long invoiceId, BigDecimal paymentPrice, BigDecimal oldPaymentPrice) {
        ErpPurchaseInvoiceDO invoice = invoiceService.getPurchaseInvoice(invoiceId);
        BigDecimal paidPrice = financePaymentMapper.selectPaymentPriceMapByInvoiceId(List.of(invoiceId))
                .getOrDefault(invoiceId, BigDecimal.ZERO);
        if (oldPaymentPrice != null) {
            paidPrice = paidPrice.subtract(oldPaymentPrice); // æŽ’除该付款单自身当前已付金额
        }
        BigDecimal remainingPrice = invoice.getPrice().subtract(paidPrice);
        if (paymentPrice.compareTo(remainingPrice) > 0) {
            throw exception(FINANCE_PAYMENT_CREATE_FAIL_INVOICE_PRICE_EXCEEDS, remainingPrice);
        }
    }
    @Override
    public ErpFinancePaymentDO getFinancePayment(Long id) {
        return financePaymentMapper.selectById(id);
@@ -234,6 +301,11 @@
        return financePaymentMapper.selectPage(pageReqVO);
    }
    @Override
    public Map<Long, BigDecimal> getPaymentPriceMapByInvoiceId(Collection<Long> invoiceIds) {
        return financePaymentMapper.selectPaymentPriceMapByInvoiceId(invoiceIds);
    }
    // ==================== ä»˜æ¬¾å•项 ====================
    @Override
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,119 @@
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.ErpPurchaseInvoicePageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.invoice.ErpPurchaseInvoiceSaveReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInvoiceDO;
import jakarta.validation.Valid;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
/**
 * ERP é‡‡è´­æ¥ç¥¨ Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface ErpPurchaseInvoiceService {
    /**
     * åˆ›å»ºæ¥ç¥¨
     *
     * @param createReqVO åˆ›å»ºä¿¡æ¯
     * @return ç¼–号
     */
    Long createPurchaseInvoice(@Valid ErpPurchaseInvoiceSaveReqVO createReqVO);
    /**
     * æ›´æ–°æ¥ç¥¨
     *
     * @param updateReqVO æ›´æ–°ä¿¡æ¯
     */
    void updatePurchaseInvoice(@Valid ErpPurchaseInvoiceSaveReqVO updateReqVO);
    /**
     * åˆ é™¤æ¥ç¥¨
     *
     * @param id ç¼–号
     */
    void deletePurchaseInvoice(Long id);
    /**
     * æäº¤æ¥ç¥¨å®¡æ‰¹
     *
     * @param id ç¼–号
     * @param userId ç”¨æˆ·ç¼–号
     */
    void submitPurchaseInvoice(Long id, Long userId);
    /**
     * æ›´æ–°æ¥ç¥¨å®¡æ‰¹ç»“果(BPM å›žè°ƒï¼‰
     *
     * @param id ç¼–号
     * @param bpmResult BPM å®¡æ‰¹ç»“æžœ
     */
    void updatePurchaseInvoiceAuditStatus(Long id, Integer bpmResult);
    /**
     * èŽ·å¾—æ¥ç¥¨
     *
     * @param id ç¼–号
     * @return æ¥ç¥¨
     */
    ErpPurchaseInvoiceDO getPurchaseInvoice(Long id);
    /**
     * èŽ·å¾—æ¥ç¥¨åˆ—è¡¨
     *
     * @param ids ç¼–号列表
     * @return æ¥ç¥¨åˆ—表
     */
    List<ErpPurchaseInvoiceDO> getPurchaseInvoiceList(Collection<Long> ids);
    /**
     * èŽ·å¾—æ¥ç¥¨ Map
     *
     * @param ids ç¼–号列表
     * @return æ¥ç¥¨ Map
     */
    default Map<Long, ErpPurchaseInvoiceDO> getPurchaseInvoiceMap(Collection<Long> ids) {
        return convertMap(getPurchaseInvoiceList(ids), ErpPurchaseInvoiceDO::getId);
    }
    /**
     * èŽ·å¾—æ¥ç¥¨åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return æ¥ç¥¨åˆ†é¡µ
     */
    PageResult<ErpPurchaseInvoiceDO> getPurchaseInvoicePage(ErpPurchaseInvoicePageReqVO pageReqVO);
    /**
     * æ ¹æ®é‡‡è´­è®¢å•编号和审批状态获得来票列表
     *
     * @param orderId é‡‡è´­è®¢å•编号
     * @param auditStatuses å®¡æ‰¹çŠ¶æ€é›†åˆ
     * @return æ¥ç¥¨åˆ—表
     */
    List<ErpPurchaseInvoiceDO> getPurchaseInvoiceListByOrderIdAndStatus(Long orderId, Collection<Integer> auditStatuses);
    /**
     * æ ¹æ®ä¾›åº”商编号和审批状态获得来票列表
     *
     * @param supplierId ä¾›åº”商编号
     * @param auditStatuses å®¡æ‰¹çŠ¶æ€é›†åˆ
     * @return æ¥ç¥¨åˆ—表
     */
    List<ErpPurchaseInvoiceDO> getPurchaseInvoiceListBySupplierIdAndStatus(Long supplierId, Collection<Integer> auditStatuses);
    /**
     * èŽ·å¾—å¾…å®¡æ ¸æ¥ç¥¨æ•°é‡
     *
     * @return æ•°é‡
     */
    Long getAuditInvoiceCount();
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,248 @@
package cn.iocoder.yudao.module.erp.service.purchase;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.enums.task.BpmTaskStatusEnum;
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.mysql.purchase.ErpPurchaseInvoiceMapper;
import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseInvoiceAuditStatusEnum;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
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 static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getSumValue;
import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*;
/**
 * ERP é‡‡è´­æ¥ç¥¨ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class ErpPurchaseInvoiceServiceImpl implements ErpPurchaseInvoiceService {
    /**
     * BPM æ¥ç¥¨å®¡æ‰¹æµç¨‹æ ‡è¯†
     */
    public static final String BPM_PROCESS_DEFINITION_KEY = "erp-purchase-invoice-audit";
    /**
     * æ¥ç¥¨é™„件的业务记录类型,对应 {@link cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum#ERP_PURCHASE_INVOICE}
     */
    public static final String PURCHASE_INVOICE_RECORD_TYPE = "erp_purchase_invoice";
    @Resource
    private ErpPurchaseInvoiceMapper invoiceMapper;
    @Resource
    private ErpNoRedisDAO noRedisDAO;
    @Resource
    private ErpPurchaseOrderService purchaseOrderService;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createPurchaseInvoice(ErpPurchaseInvoiceSaveReqVO createReqVO) {
        // 1.1 æ ¡éªŒå…³è”数据存在
        validateRelationDataExists(createReqVO);
        // 1.2 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        validatePurchaseInvoicePriceExceedsLimit(createReqVO);
        // 1.3 ç”Ÿæˆæ¥ç¥¨ç¼–号
        String no = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_INVOICE_NO_PREFIX);
        if (invoiceMapper.selectByNo(no) != null) {
            throw exception(PURCHASE_INVOICE_NO_EXISTS);
        }
        // 2.1 æ’入来票
        ErpPurchaseInvoiceDO invoice = BeanUtils.toBean(createReqVO, ErpPurchaseInvoiceDO.class)
                .setNo(no).setAuditStatus(ErpPurchaseInvoiceAuditStatusEnum.DRAFT.getStatus());
        invoiceMapper.insert(invoice);
        // 2.2 ç»‘定发票附件
        if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
            storageAttachmentApi.bindAttachments("file", PURCHASE_INVOICE_RECORD_TYPE, invoice.getId(), createReqVO.getBlobIds());
        }
        return invoice.getId();
    }
    private void validateRelationDataExists(ErpPurchaseInvoiceSaveReqVO reqVO) {
        // æ ¡éªŒé‡‡è´­è®¢å•,必须审核通过
        ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.validatePurchaseOrder(reqVO.getPurchaseOrderId());
        // æ ¡éªŒä¾›åº”商与采购订单一致(防御脏数据:来票必须属于该采购订单的供应商)
        if (reqVO.getSupplierId() != null && ObjectUtil.notEqual(purchaseOrder.getSupplierId(), reqVO.getSupplierId())) {
            throw exception(PURCHASE_INVOICE_CREATE_FAIL_SUPPLIER_MISMATCH);
        }
        reqVO.setSupplierId(purchaseOrder.getSupplierId()); // ä¾›åº”商以采购订单为准
    }
    private void validatePurchaseInvoicePriceExceedsLimit(ErpPurchaseInvoiceSaveReqVO reqVO) {
        // 1. è®¡ç®—剩余可开票金额,不包括 reqVO è‡ªèº«ï¼ˆå®¡æ‰¹ä¸­ä¹Ÿå ç”¨é¢åº¦ï¼Œé˜²æ­¢ç´¯è®¡æ¥ç¥¨è¶…过采购订单金额)
        ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.getPurchaseOrder(reqVO.getPurchaseOrderId());
        List<ErpPurchaseInvoiceDO> invoices = invoiceMapper.selectListByOrderIdAndStatus(reqVO.getPurchaseOrderId(),
                List.of(ErpPurchaseInvoiceAuditStatusEnum.DRAFT.getStatus(),
                        ErpPurchaseInvoiceAuditStatusEnum.PROCESS.getStatus(),
                        ErpPurchaseInvoiceAuditStatusEnum.APPROVE.getStatus()));
        if (reqVO.getId() != null) {
            invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), reqVO.getId()));
        }
        BigDecimal remainPrice = purchaseOrder.getTotalPrice().subtract(
                getSumValue(invoices, ErpPurchaseInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO));
        // 2. æ ¡éªŒé‡‘额是否超过
        if (reqVO.getPrice().compareTo(remainPrice) > 0) {
            throw exception(PURCHASE_INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, remainPrice);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updatePurchaseInvoice(ErpPurchaseInvoiceSaveReqVO updateReqVO) {
        Assert.notNull(updateReqVO.getId(), "来票编号不能为空");
        // 1.1 æ ¡éªŒå­˜åœ¨
        ErpPurchaseInvoiceDO oldInvoice = validatePurchaseInvoiceExists(updateReqVO.getId());
        // 1.2 åªæœ‰è‰ç¨¿å¯ä»¥ç¼–辑
        if (ObjectUtil.notEqual(oldInvoice.getAuditStatus(), ErpPurchaseInvoiceAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(PURCHASE_INVOICE_UPDATE_FAIL_EDITING_PROHIBITED, oldInvoice.getNo());
        }
        // 1.3 æ ¡éªŒå…³è”数据 + å¯å¼€ç¥¨é‡‘额
        validateRelationDataExists(updateReqVO);
        validatePurchaseInvoicePriceExceedsLimit(updateReqVO);
        // 2. æ›´æ–°æ¥ç¥¨
        ErpPurchaseInvoiceDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseInvoiceDO.class);
        invoiceMapper.updateById(updateObj);
        // 3. å¢žé‡æ›´æ–°å‘票附件
        storageAttachmentApi.updateAttachments("file", PURCHASE_INVOICE_RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deletePurchaseInvoice(Long id) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        ErpPurchaseInvoiceDO invoice = validatePurchaseInvoiceExists(id);
        // 1.2 å®¡æ‰¹é€šè¿‡æ—¶ï¼Œä¸å…è®¸åˆ é™¤
        if (ObjectUtil.equal(invoice.getAuditStatus(), ErpPurchaseInvoiceAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(PURCHASE_INVOICE_DELETE_FAIL_IS_APPROVE);
        }
        // 2.1 åˆ é™¤å‘票附件
        storageAttachmentApi.deleteAttachmentsByRecord(PURCHASE_INVOICE_RECORD_TYPE, id);
        // 2.2 åˆ é™¤æ¥ç¥¨
        invoiceMapper.deleteById(id);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitPurchaseInvoice(Long id, Long userId) {
        // 1. æ ¡éªŒæ¥ç¥¨æ˜¯å¦åœ¨å®¡æ‰¹
        ErpPurchaseInvoiceDO invoice = validatePurchaseInvoiceExists(id);
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), ErpPurchaseInvoiceAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(PURCHASE_INVOICE_SUBMIT_FAIL_NOT_DRAFT);
        }
        // 1.1 é‡æ–°æ ¡éªŒå¯å¼€ç¥¨é‡‘额,防止累计来票超过采购订单金额(草稿期后可能有其他单进入审批中)
        validatePurchaseInvoicePriceExceedsLimit(new ErpPurchaseInvoiceSaveReqVO()
                .setId(invoice.getId()).setPurchaseOrderId(invoice.getPurchaseOrderId())
                .setSupplierId(invoice.getSupplierId()).setPrice(invoice.getPrice()));
        // 2. åˆ›å»ºæ¥ç¥¨å®¡æ‰¹æµç¨‹å®žä¾‹
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id)));
        // 3. æ›´æ–°æ¥ç¥¨å·¥ä½œæµç¼–号
        invoiceMapper.updateById(new ErpPurchaseInvoiceDO().setId(id).setProcessInstanceId(processInstanceId)
                .setAuditStatus(ErpPurchaseInvoiceAuditStatusEnum.PROCESS.getStatus()));
    }
    @Override
    public void updatePurchaseInvoiceAuditStatus(Long id, Integer bpmResult) {
        // 1. æ ¡éªŒå­˜åœ¨ + å®¡æ‰¹ä¸­çŠ¶æ€
        ErpPurchaseInvoiceDO invoice = validatePurchaseInvoiceExists(id);
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), ErpPurchaseInvoiceAuditStatusEnum.PROCESS.getStatus())) {
            log.warn("[updatePurchaseInvoiceAuditStatus][来票({}) ä¸å¤„于审批中,无法更新审批结果({})]", id, bpmResult);
            return;
        }
        // 2. æ ¹æ®å®¡æ‰¹ç»“果更新状态
        Integer auditStatus = convertBpmResultToAuditStatus(bpmResult);
        if (auditStatus != null) {
            invoiceMapper.updateById(new ErpPurchaseInvoiceDO().setId(id).setAuditStatus(auditStatus));
        }
    }
    private Integer convertBpmResultToAuditStatus(Integer bpmResult) {
        if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            return ErpPurchaseInvoiceAuditStatusEnum.APPROVE.getStatus();
        } else if (BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)) {
            return ErpPurchaseInvoiceAuditStatusEnum.REJECT.getStatus();
        } else if (BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            return ErpPurchaseInvoiceAuditStatusEnum.CANCEL.getStatus();
        }
        return null;
    }
    private ErpPurchaseInvoiceDO validatePurchaseInvoiceExists(Long id) {
        ErpPurchaseInvoiceDO invoice = invoiceMapper.selectById(id);
        if (invoice == null) {
            throw exception(PURCHASE_INVOICE_NOT_EXISTS);
        }
        return invoice;
    }
    @Override
    public ErpPurchaseInvoiceDO getPurchaseInvoice(Long id) {
        return invoiceMapper.selectById(id);
    }
    @Override
    public List<ErpPurchaseInvoiceDO> getPurchaseInvoiceList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return List.of();
        }
        return invoiceMapper.selectByIds(ids);
    }
    @Override
    public PageResult<ErpPurchaseInvoiceDO> getPurchaseInvoicePage(ErpPurchaseInvoicePageReqVO pageReqVO) {
        return invoiceMapper.selectPage(pageReqVO);
    }
    @Override
    public List<ErpPurchaseInvoiceDO> getPurchaseInvoiceListByOrderIdAndStatus(Long orderId, Collection<Integer> auditStatuses) {
        return invoiceMapper.selectListByOrderIdAndStatus(orderId, auditStatuses);
    }
    @Override
    public List<ErpPurchaseInvoiceDO> getPurchaseInvoiceListBySupplierIdAndStatus(Long supplierId, Collection<Integer> auditStatuses) {
        return invoiceMapper.selectListBySupplierIdAndStatus(supplierId, auditStatuses);
    }
    @Override
    public Long getAuditInvoiceCount() {
        return invoiceMapper.selectCountByAudit();
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/listener/ErpPurchaseInvoiceStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.erp.service.purchase.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceService;
import cn.iocoder.yudao.module.erp.service.purchase.ErpPurchaseInvoiceServiceImpl;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * é‡‡è´­æ¥ç¥¨å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
 * ç›‘听 BPM æµç¨‹å®žä¾‹çŠ¶æ€å˜æ›´äº‹ä»¶ï¼Œæ›´æ–°æ¥ç¥¨çš„å®¡æ‰¹çŠ¶æ€
 *
 * @author è¶…级管理员
 */
@Component
@Slf4j
public class ErpPurchaseInvoiceStatusListener extends BpmProcessInstanceStatusEventListener {
    @Resource
    private ErpPurchaseInvoiceService invoiceService;
    @Override
    protected String getProcessDefinitionKey() {
        return ErpPurchaseInvoiceServiceImpl.BPM_PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        Long invoiceId;
        try {
            invoiceId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException e) {
            log.warn("[onEvent] businessKey({}) ä¸æ˜¯æœ‰æ•ˆçš„æ¥ç¥¨ç¼–号", event.getBusinessKey());
            return;
        }
        log.info("[onEvent] æ”¶åˆ°BPM审批事件,invoiceId={}, status={}", invoiceId, event.getStatus());
        invoiceService.updatePurchaseInvoiceAuditStatus(invoiceId, event.getStatus());
    }
}
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -21,6 +21,11 @@
    ErrorCode EMPLOYEE_POST_NAME_NOT_EXISTS = new ErrorCode(1_030_000_008, "岗位【{}】不存在");
    ErrorCode EMPLOYEE_ROLE_NAME_NOT_EXISTS = new ErrorCode(1_030_000_009, "角色【{}】不存在");
    ErrorCode EMPLOYEE_IMPORT_FIELD_REQUIRED = new ErrorCode(1_030_000_010, "导入字段【{}】不能为空");
    ErrorCode EMPLOYEE_CONTRACT_NOT_EXISTS = new ErrorCode(1_030_000_011, "员工合同不存在");
    ErrorCode EMPLOYEE_CONTRACT_TERMINATE_REQUIRE_DATE_REASON = new ErrorCode(1_030_000_012, "解除/终止合同必须填写日期和原因");
    ErrorCode EMPLOYEE_CONTRACT_END_DATE_REQUIRE_FOR_FIXED = new ErrorCode(1_030_000_013, "固定期限合同必须填写结束日期");
    ErrorCode EMPLOYEE_CONTRACT_DATE_INVALID = new ErrorCode(1_030_000_014, "合同日期不合法,开始日期不能晚于结束日期");
    ErrorCode EMPLOYEE_CONTRACT_DATE_OVERLAP = new ErrorCode(1_030_000_015, "该员工在所选时间段已存在未解除/未终止的合同,请先解除/终止后再签订");
    // ========== è¯·å‡ç”³è¯· 1-030-001-000 ==========
    ErrorCode LEAVE_APPLICATION_NOT_EXISTS = new ErrorCode(1_030_001_000, "请假申请不存在");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeContractController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
package cn.iocoder.yudao.module.hrm.controller.admin.employee;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractTerminateReqVO;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeContractService;
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 jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - HRM å‘˜å·¥åˆåŒ")
@RestController
@RequestMapping("/hrm/employee-contract")
@Validated
public class HrmEmployeeContractController {
    @Resource
    private HrmEmployeeContractService contractService;
    @PostMapping("/create")
    @Operation(summary = "创建员工合同")
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:create')")
    public CommonResult<Long> createContract(@Valid @RequestBody HrmEmployeeContractSaveReqVO createReqVO) {
        return success(contractService.createContract(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新员工合同")
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:update')")
    public CommonResult<Boolean> updateContract(@Valid @RequestBody HrmEmployeeContractSaveReqVO updateReqVO) {
        contractService.updateContract(updateReqVO);
        return success(true);
    }
    @PutMapping("/terminate")
    @Operation(summary = "解除/终止员工合同")
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:update')")
    public CommonResult<Boolean> terminateContract(@Valid @RequestBody HrmEmployeeContractTerminateReqVO reqVO) {
        contractService.terminateContract(reqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除员工合同")
    @Parameter(name = "id", description = "合同ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:delete')")
    public CommonResult<Boolean> deleteContract(@RequestParam("id") Long id) {
        contractService.deleteContract(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得员工合同详情")
    @Parameter(name = "id", description = "合同ID", required = true)
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:query')")
    public CommonResult<HrmEmployeeContractRespVO> getContract(@RequestParam("id") Long id) {
        return success(contractService.getContract(id));
    }
    @GetMapping("/page")
    @Operation(summary = "获得员工合同分页")
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:query')")
    public CommonResult<PageResult<HrmEmployeeContractRespVO>> getContractPage(@Valid HrmEmployeeContractPageReqVO pageReqVO) {
        return success(contractService.getContractPage(pageReqVO));
    }
    @GetMapping("/expiring-count")
    @Operation(summary = "获得即将到期合同数量", description = "供 HRM å¾…办/首页提醒使用")
    @PreAuthorize("@ss.hasPermission('hrm:employee-contract:query')")
    public CommonResult<Long> getExpiringCount() {
        return success(contractService.getExpiringCount());
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.hrm.controller.admin.employee.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - å‘˜å·¥åˆåŒåˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class HrmEmployeeContractPageReqVO extends PageParam {
    /**
     * åˆåŒçŠ¶æ€ - å¾…生效
     */
    public static final Integer STATUS_PENDING = 1;
    /**
     * åˆåŒçŠ¶æ€ - ç”Ÿæ•ˆä¸­
     */
    public static final Integer STATUS_ACTIVE = 2;
    /**
     * åˆåŒçŠ¶æ€ - å³å°†åˆ°æœŸ
     */
    public static final Integer STATUS_EXPIRING_SOON = 3;
    /**
     * åˆåŒçŠ¶æ€ - å·²åˆ°æœŸ
     */
    public static final Integer STATUS_EXPIRED = 4;
    /**
     * åˆåŒçŠ¶æ€ - å·²è§£é™¤
     */
    public static final Integer STATUS_CANCELED = 5;
    /**
     * åˆåŒçŠ¶æ€ - å·²ç»ˆæ­¢
     */
    public static final Integer STATUS_TERMINATED = 6;
    /**
     * åˆ°æœŸç±»åž‹ - å³å°†åˆ°æœŸ
     */
    public static final Integer EXPIRY_TYPE_ABOUT_TO_EXPIRE = 1;
    /**
     * åˆ°æœŸç±»åž‹ - å·²åˆ°æœŸ
     */
    public static final Integer EXPIRY_TYPE_EXPIRED = 2;
    @Schema(description = "合同编号", example = "HT202608100001")
    private String contractNo;
    @Schema(description = "员工ID", example = "1")
    private Long employeeId;
    @Schema(description = "合同类型", example = "1")
    private Integer contractType;
    @Schema(description = "合同期限类型", example = "1")
    private Integer contractTermType;
    @Schema(description = "解除/终止状态:0-正常 1-已解除 2-已终止", example = "0")
    private Integer terminateStatus;
    @Schema(description = "合同状态:1-待生效 2-生效中 3-即将到期 4-已到期 5-已解除 6-已终止(动态计算)", example = "3")
    private Integer status;
    @Schema(description = "到期类型:1-即将到期 2-已到期(待办提醒用)", example = "1")
    private Integer expiryType;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.hrm.controller.admin.employee.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - å‘˜å·¥åˆåŒ Response VO")
@Data
public class HrmEmployeeContractRespVO {
    @Schema(description = "合同ID", example = "1")
    private Long id;
    @Schema(description = "续签上一份合同ID", example = "1")
    private Long parentId;
    @Schema(description = "续签上一份合同编号", example = "HT202608100001")
    private String parentNo;
    @Schema(description = "是否当前生效合同", example = "true")
    private Boolean isCurrent;
    @Schema(description = "合同编号", example = "HT202608100001")
    private String contractNo;
    @Schema(description = "员工ID", example = "1")
    private Long employeeId;
    @Schema(description = "员工姓名", example = "张三")
    private String employeeName;
    @Schema(description = "员工工号", example = "EMP202401010001")
    private String employeeNo;
    @Schema(description = "部门名称", example = "研发部")
    private String deptName;
    @Schema(description = "合同类型", example = "1")
    private Integer contractType;
    @Schema(description = "合同期限类型", example = "1")
    private Integer contractTermType;
    @Schema(description = "签约主体(公司全称)", example = "南通XX科技有限公司")
    private String signCompany;
    @Schema(description = "签订日期")
    private LocalDate signDate;
    @Schema(description = "合同开始日期")
    private LocalDate startDate;
    @Schema(description = "合同结束日期(无固定期限可为空)")
    private LocalDate endDate;
    @Schema(description = "试用期开始日期")
    private LocalDate probationStartDate;
    @Schema(description = "试用期结束日期")
    private LocalDate probationEndDate;
    @Schema(description = "试用期工资", example = "8000.00")
    private BigDecimal probationSalary;
    @Schema(description = "转正工资", example = "10000.00")
    private BigDecimal regularSalary;
    @Schema(description = "合同状态:1-待生效 2-生效中 3-即将到期 4-已到期 5-已解除 6-已终止(动态计算)", example = "2")
    private Integer status;
    @Schema(description = "解除/终止状态:0-正常 1-已解除 2-已终止", example = "0")
    private Integer terminateStatus;
    @Schema(description = "解除/终止日期")
    private LocalDate terminateDate;
    @Schema(description = "解除/终止原因", example = "合同到期不续签")
    private String terminateReason;
    @Schema(description = "备注", example = "第一次续签")
    private String remark;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
package cn.iocoder.yudao.module.hrm.controller.admin.employee.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - å‘˜å·¥åˆåŒæ–°å¢ž/修改 Request VO")
@Data
public class HrmEmployeeContractSaveReqVO {
    @Schema(description = "合同ID", example = "1")
    private Long id;
    @Schema(description = "续签上一份合同ID(续签时指定;一般不传,后端自动关联该员工最近一份合同)", example = "1")
    private Long parentId;
    @Schema(description = "员工ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "员工不能为空")
    private Long employeeId;
    @Schema(description = "合同类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "合同类型不能为空")
    private Integer contractType;
    @Schema(description = "合同期限类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "合同期限类型不能为空")
    private Integer contractTermType;
    @Schema(description = "签约主体(公司全称)", example = "南通XX科技有限公司")
    private String signCompany;
    @Schema(description = "签订日期")
    private LocalDate signDate;
    @Schema(description = "合同开始日期", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "合同开始日期不能为空")
    private LocalDate startDate;
    @Schema(description = "合同结束日期(无固定期限可为空)")
    private LocalDate endDate;
    @Schema(description = "试用期开始日期")
    private LocalDate probationStartDate;
    @Schema(description = "试用期结束日期")
    private LocalDate probationEndDate;
    @Schema(description = "试用期工资", example = "8000.00")
    private BigDecimal probationSalary;
    @Schema(description = "转正工资", example = "10000.00")
    private BigDecimal regularSalary;
    @Schema(description = "解除/终止状态:0-正常 1-已解除 2-已终止", example = "0")
    private Integer terminateStatus;
    @Schema(description = "解除/终止日期")
    private LocalDate terminateDate;
    @Schema(description = "解除/终止原因", example = "合同到期不续签")
    private String terminateReason;
    @Schema(description = "备注", example = "第一次续签")
    private String remark;
    @Schema(description = "附件 blobId åˆ—表", example = "[1, 2]")
    private List<Long> blobIds;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractTerminateReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.hrm.controller.admin.employee.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
@Schema(description = "管理后台 - å‘˜å·¥åˆåŒè§£é™¤/终止 Request VO")
@Data
public class HrmEmployeeContractTerminateReqVO {
    @Schema(description = "合同ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "合同ID不能为空")
    private Long id;
    @Schema(description = "解除/终止状态:1-已解除 2-已终止", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "解除/终止状态不能为空")
    private Integer terminateStatus;
    @Schema(description = "解除/终止日期", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "解除/终止日期不能为空")
    private LocalDate terminateDate;
    @Schema(description = "解除/终止原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "协商一致解除")
    private String terminateReason;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/employee/HrmEmployeeContractDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
package cn.iocoder.yudao.module.hrm.dal.dataobject.employee;
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;
import java.time.LocalDate;
/**
 * å‘˜å·¥åˆåŒ DO
 *
 * è®°å½•员工与公司签订的劳动合同/续签合同等,一个员工可有多份合同(历史合同 + å½“前合同)
 *
 * @author è¶…级管理员
 */
@TableName("hrm_employee_contract")
@KeySequence("hrm_employee_contract_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HrmEmployeeContractDO extends BaseDO {
    /**
     * åˆåŒID
     */
    @TableId
    private Long id;
    /**
     * ç»­ç­¾ä¸Šä¸€ä»½åˆåŒID
     *
     * ä¸€ä¸ªå‘˜å·¥å¯ç­¾è®¢å¤šæ¬¡åˆåŒï¼Œé€šè¿‡è¯¥å­—段形成续签链,便于追溯合同历史
     */
    private Long parentId;
    /**
     * æ˜¯å¦å½“前生效合同
     *
     * å‘˜å·¥æœ€æ–°ç­¾è®¢çš„、未解除/未终止的合同标记为当前合同,同一员工仅一份
     */
    private Boolean isCurrent;
    /**
     * åˆåŒç¼–号
     *
     * è‡ªåŠ¨ç”Ÿæˆï¼šHT + yyyyMMdd + 4位自增
     */
    private String contractNo;
    /**
     * å‘˜å·¥ID
     *
     * å…³è” HrmEmployeeDO#getId()
     */
    private Long employeeId;
    /**
     * åˆåŒç±»åž‹
     *
     * å­—å…¸ hrm_contract_type:1-劳动合同 2-劳务合同 3-实习协议 4-劳务派遣 5-其他
     */
    private Integer contractType;
    /**
     * åˆåŒæœŸé™ç±»åž‹
     *
     * å­—å…¸ hrm_contract_term_type:1-固定期限 2-无固定期限 3-以完成一定工作任务为期限
     */
    private Integer contractTermType;
    /**
     * ç­¾çº¦ä¸»ä½“(公司全称)
     */
    private String signCompany;
    /**
     * ç­¾è®¢æ—¥æœŸ
     */
    private LocalDate signDate;
    /**
     * åˆåŒå¼€å§‹æ—¥æœŸ
     */
    private LocalDate startDate;
    /**
     * åˆåŒç»“束日期
     *
     * æ— å›ºå®šæœŸé™åˆåŒå¯ä¸ºç©º
     */
    private LocalDate endDate;
    /**
     * è¯•用期开始日期
     */
    private LocalDate probationStartDate;
    /**
     * è¯•用期结束日期
     */
    private LocalDate probationEndDate;
    /**
     * è¯•用期工资
     */
    private BigDecimal probationSalary;
    /**
     * è½¬æ­£å·¥èµ„
     */
    private BigDecimal regularSalary;
    /**
     * è§£é™¤/终止状态
     *
     * 0-正常 1-已解除 2-已终止
     */
    private Integer terminateStatus;
    /**
     * è§£é™¤/终止日期
     */
    private LocalDate terminateDate;
    /**
     * è§£é™¤/终止原因
     */
    private String terminateReason;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeContractMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,190 @@
package cn.iocoder.yudao.module.hrm.dal.mysql.employee;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractPageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeContractDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
import java.util.List;
/**
 * å‘˜å·¥åˆåŒ Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface HrmEmployeeContractMapper extends BaseMapperX<HrmEmployeeContractDO> {
    /**
     * åˆ°æœŸæé†’天数
     */
    int REMIND_DAYS = 30;
    /**
     * åˆåŒçŠ¶æ€ï¼šå¾…ç”Ÿæ•ˆ
     */
    int STATUS_PENDING = 1;
    /**
     * åˆåŒçŠ¶æ€ï¼šç”Ÿæ•ˆä¸­
     */
    int STATUS_ACTIVE = 2;
    /**
     * åˆåŒçŠ¶æ€ï¼šå³å°†åˆ°æœŸ
     */
    int STATUS_EXPIRING_SOON = 3;
    /**
     * åˆåŒçŠ¶æ€ï¼šå·²åˆ°æœŸ
     */
    int STATUS_EXPIRED = 4;
    /**
     * è§£é™¤ç»ˆæ­¢çŠ¶æ€ï¼šæ­£å¸¸
     */
    int TERMINATE_NORMAL = 0;
    /**
     * è§£é™¤ç»ˆæ­¢çŠ¶æ€ï¼šå·²è§£é™¤
     */
    int TERMINATE_CANCELED = 1;
    /**
     * è§£é™¤ç»ˆæ­¢çŠ¶æ€ï¼šå·²ç»ˆæ­¢
     */
    int TERMINATE_TERMINATED = 2;
    default PageResult<HrmEmployeeContractDO> selectPage(HrmEmployeeContractPageReqVO reqVO, LocalDate today) {
        LambdaQueryWrapperX<HrmEmployeeContractDO> query = new LambdaQueryWrapperX<HrmEmployeeContractDO>()
                .likeIfPresent(HrmEmployeeContractDO::getContractNo, reqVO.getContractNo())
                .eqIfPresent(HrmEmployeeContractDO::getEmployeeId, reqVO.getEmployeeId())
                .eqIfPresent(HrmEmployeeContractDO::getContractType, reqVO.getContractType())
                .eqIfPresent(HrmEmployeeContractDO::getContractTermType, reqVO.getContractTermType())
                .eqIfPresent(HrmEmployeeContractDO::getTerminateStatus, reqVO.getTerminateStatus())
                .orderByDesc(HrmEmployeeContractDO::getId);
        // åŠ¨æ€çŠ¶æ€ç­›é€‰ï¼ˆä¸Ž terminateStatus äº’斥,优先按状态筛选)
        if (reqVO.getStatus() != null) {
            appendStatusCondition(query, reqVO.getStatus(), today);
        } else if (reqVO.getExpiryType() != null) {
            appendExpiryCondition(query, reqVO.getExpiryType(), today);
        }
        return selectPage(reqVO, query);
    }
    /**
     * æ‹¼æŽ¥åŠ¨æ€çŠ¶æ€ç­›é€‰æ¡ä»¶
     *
     * @param status åˆåŒçŠ¶æ€ï¼š1-待生效 2-生效中 3-即将到期 4-已到期 5-已解除 6-已终止
     */
    default void appendStatusCondition(LambdaQueryWrapperX<HrmEmployeeContractDO> query, Integer status, LocalDate today) {
        switch (status) {
            case STATUS_PENDING: // å¾…生效
                query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL)
                        .gt(HrmEmployeeContractDO::getStartDate, today);
                break;
            case STATUS_ACTIVE: // ç”Ÿæ•ˆä¸­ï¼šæœªè§£é™¤/终止,已开始,且未结束(含无固定期限)
                query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL)
                        .le(HrmEmployeeContractDO::getStartDate, today)
                        .and(w -> w.isNull(HrmEmployeeContractDO::getEndDate)
                                .or().ge(HrmEmployeeContractDO::getEndDate, today));
                break;
            case STATUS_EXPIRING_SOON: // å³å°†åˆ°æœŸ
                query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL)
                        .le(HrmEmployeeContractDO::getStartDate, today)
                        .ge(HrmEmployeeContractDO::getEndDate, today)
                        .le(HrmEmployeeContractDO::getEndDate, today.plusDays(REMIND_DAYS));
                break;
            case STATUS_EXPIRED: // å·²åˆ°æœŸ
                query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL)
                        .isNotNull(HrmEmployeeContractDO::getEndDate)
                        .lt(HrmEmployeeContractDO::getEndDate, today);
                break;
            case 5: // å·²è§£é™¤
                query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_CANCELED);
                break;
            case 6: // å·²ç»ˆæ­¢
                query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_TERMINATED);
                break;
            default:
                break;
        }
    }
    /**
     * æ‹¼æŽ¥åˆ°æœŸç±»åž‹ç­›é€‰æ¡ä»¶ï¼ˆå¾…办提醒用)
     *
     * @param expiryType åˆ°æœŸç±»åž‹ï¼š1-即将到期 2-已到期
     */
    default void appendExpiryCondition(LambdaQueryWrapperX<HrmEmployeeContractDO> query, Integer expiryType, LocalDate today) {
        if (expiryType == 1) { // å³å°†åˆ°æœŸ
            query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL)
                    .le(HrmEmployeeContractDO::getStartDate, today)
                    .between(HrmEmployeeContractDO::getEndDate, today, today.plusDays(REMIND_DAYS));
        } else if (expiryType == 2) { // å·²åˆ°æœŸ
            query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL)
                    .isNotNull(HrmEmployeeContractDO::getEndDate)
                    .lt(HrmEmployeeContractDO::getEndDate, today);
        }
    }
    /**
     * ç»Ÿè®¡å³å°†åˆ°æœŸçš„合同数量(供待办/首页提醒)
     */
    default Long selectCountByExpiring(LocalDate today) {
        return selectCount(new LambdaQueryWrapperX<HrmEmployeeContractDO>()
                .eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL)
                .le(HrmEmployeeContractDO::getStartDate, today)
                .between(HrmEmployeeContractDO::getEndDate, today, today.plusDays(REMIND_DAYS)));
    }
    /**
     * æŸ¥è¯¢åŒä¸€å‘˜å·¥æ—¶é—´é‡å çš„æœªè§£é™¤/未终止合同
     *
     * @param employeeId å‘˜å·¥ID
     * @param startDate  æ–°åˆåŒå¼€å§‹æ—¥æœŸ
     * @param endDate    æ–°åˆåŒç»“束日期(无固定期限为 null,视为无限长)
     * @param excludeId  éœ€è¦æŽ’除的合同ID(更新时传自身 id,创建时传 null)
     * @return é‡å çš„合同列表
     */
    default List<HrmEmployeeContractDO> selectOverlapping(Long employeeId, LocalDate startDate,
                                                          LocalDate endDate, Long excludeId) {
        LambdaQueryWrapperX<HrmEmployeeContractDO> query = new LambdaQueryWrapperX<HrmEmployeeContractDO>();
        query.eq(HrmEmployeeContractDO::getEmployeeId, employeeId);
        query.eq(HrmEmployeeContractDO::getTerminateStatus, TERMINATE_NORMAL);
        if (excludeId != null) {
            query.ne(HrmEmployeeContractDO::getId, excludeId);
        }
        if (endDate != null) {
            // æ–°åˆåŒæœ‰ç»“束日期:与已存在合同的 [startDate, endDate] åŒºé—´å­˜åœ¨äº¤é›†å³å†²çª
            query.lt(HrmEmployeeContractDO::getStartDate, endDate);
            query.and(w -> w.isNull(HrmEmployeeContractDO::getEndDate)
                    .or().gt(HrmEmployeeContractDO::getEndDate, startDate));
        }
        // æ–°åˆåŒæ— å›ºå®šæœŸé™ï¼ˆendDate ä¸º null):员工存在任意未解除/终止合同即冲突
        return selectList(query);
    }
    /**
     * æ¸…除该员工全部合同的"当前合同"标记
     *
     * @param employeeId å‘˜å·¥ID
     */
    default void clearCurrentByEmployee(Long employeeId) {
        update(null, new LambdaUpdateWrapper<HrmEmployeeContractDO>()
                .eq(HrmEmployeeContractDO::getEmployeeId, employeeId)
                .set(HrmEmployeeContractDO::getIsCurrent, false));
    }
    /**
     * æŸ¥è¯¢å‘˜å·¥çš„全部合同(按创建时间倒序)
     *
     * @param employeeId å‘˜å·¥ID
     * @return åˆåŒåˆ—表
     */
    default List<HrmEmployeeContractDO> selectListByEmployee(Long employeeId) {
        return selectList(new LambdaQueryWrapperX<HrmEmployeeContractDO>()
                .eq(HrmEmployeeContractDO::getEmployeeId, employeeId)
                .orderByDesc(HrmEmployeeContractDO::getId));
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/HrmNoRedisDAO.java
@@ -52,6 +52,11 @@
     */
    public static final String RESIGNATION_APPLICATION_NO_PREFIX = "RA";
    /**
     * å‘˜å·¥åˆåŒç¼–号前缀
     */
    public static final String EMPLOYEE_CONTRACT_NO_PREFIX = "HT";
    @Resource
    private StringRedisTemplate stringRedisTemplate;
@@ -96,6 +101,16 @@
    }
    /**
     * ç”Ÿæˆå‘˜å·¥åˆåŒç¼–号,格式:HT + yyyyMMdd + 4位自增
     * ä¾‹å¦‚:HT202401010001
     *
     * @return å‘˜å·¥åˆåŒç¼–号
     */
    public String generateEmployeeContractNo() {
        return generate(EMPLOYEE_CONTRACT_NO_PREFIX, 4);
    }
    /**
     * ç”Ÿæˆè–ªé…¬æ ¸ç®—编号,格式:SC + yyyyMMdd + 4位自增
     * ä¾‹å¦‚:SC202401010001
     *
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.hrm.service.employee;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractTerminateReqVO;
/**
 * å‘˜å·¥åˆåŒ Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface HrmEmployeeContractService {
    /**
     * åˆ›å»ºå‘˜å·¥åˆåŒ
     *
     * @param createReqVO åˆ›å»ºè¯·æ±‚
     * @return åˆåŒID
     */
    Long createContract(HrmEmployeeContractSaveReqVO createReqVO);
    /**
     * æ›´æ–°å‘˜å·¥åˆåŒ
     *
     * @param updateReqVO æ›´æ–°è¯·æ±‚
     */
    void updateContract(HrmEmployeeContractSaveReqVO updateReqVO);
    /**
     * åˆ é™¤å‘˜å·¥åˆåŒ
     *
     * @param id åˆåŒID
     */
    void deleteContract(Long id);
    /**
     * è§£é™¤/终止员工合同
     *
     * @param reqVO è§£é™¤/终止请求
     */
    void terminateContract(HrmEmployeeContractTerminateReqVO reqVO);
    /**
     * èŽ·å¾—å‘˜å·¥åˆåŒè¯¦æƒ…
     *
     * @param id åˆåŒID
     * @return åˆåŒè¯¦æƒ…(含附件、员工信息、动态状态)
     */
    HrmEmployeeContractRespVO getContract(Long id);
    /**
     * èŽ·å¾—å‘˜å·¥åˆåŒåˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µè¯·æ±‚
     * @return åˆåŒåˆ†é¡µç»“果(含员工信息、动态状态)
     */
    PageResult<HrmEmployeeContractRespVO> getContractPage(HrmEmployeeContractPageReqVO pageReqVO);
    /**
     * ç»Ÿè®¡å³å°†åˆ°æœŸçš„合同数量(供待办/首页提醒)
     *
     * @return å³å°†åˆ°æœŸåˆåŒæ•°é‡
     */
    Long getExpiringCount();
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,321 @@
package cn.iocoder.yudao.module.hrm.service.employee;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractTerminateReqVO;
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.mysql.employee.HrmEmployeeContractMapper;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
/**
 * å‘˜å·¥åˆåŒ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
public class HrmEmployeeContractServiceImpl implements HrmEmployeeContractService {
    /**
     * é™„件业务记录类型
     */
    private static final String RECORD_TYPE = "hrm_employee_contract";
    @Resource
    private HrmEmployeeContractMapper contractMapper;
    @Resource
    private HrmEmployeeService employeeService;
    @Resource
    private DeptApi deptApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private HrmNoRedisDAO noRedisDAO;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createContract(HrmEmployeeContractSaveReqVO createReqVO) {
        // 1. æ ¡éªŒå‘˜å·¥å­˜åœ¨
        employeeService.validateEmployee(createReqVO.getEmployeeId());
        // 2. æ ¡éªŒåˆåŒæ—¥æœŸ
        validateContractDate(createReqVO);
        // 3. æ ¡éªŒåŒä¸€å‘˜å·¥æ—¶é—´é‡å çš„合同
        validateNoOverlap(createReqVO.getEmployeeId(), createReqVO.getStartDate(),
                createReqVO.getEndDate(), null);
        // 4. ç”ŸæˆåˆåŒç¼–号
        String contractNo = noRedisDAO.generateEmployeeContractNo();
        // 5. æŸ¥è¯¢è¯¥å‘˜å·¥å·²æœ‰åˆåŒï¼Œç¡®å®šç»­ç­¾å…³ç³»
        List<HrmEmployeeContractDO> existing = contractMapper.selectListByEmployee(createReqVO.getEmployeeId());
        // 6. æ’入合同记录(新签合同即员工当前合同)
        HrmEmployeeContractDO contract = BeanUtils.toBean(createReqVO, HrmEmployeeContractDO.class);
        contract.setContractNo(contractNo);
        contract.setIsCurrent(true);
        // ç»­ç­¾ï¼šä¼˜å…ˆä½¿ç”¨å‰ç«¯æŒ‡å®šçš„ parentId(用户点击的那份合同),否则自动关联员工最近一份合同
        if (createReqVO.getParentId() == null) {
            contract.setParentId(CollUtil.isEmpty(existing) ? null : existing.get(0).getId());
        }
        if (contract.getTerminateStatus() == null) {
            contract.setTerminateStatus(HrmEmployeeContractMapper.TERMINATE_NORMAL);
        }
        // 7. å…ˆæ¸…除旧合同的"当前合同"标记,再插入新合同
        contractMapper.clearCurrentByEmployee(createReqVO.getEmployeeId());
        contractMapper.insert(contract);
        // 8. ç»‘定附件
        if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
            storageAttachmentApi.bindAttachments("file", RECORD_TYPE, contract.getId(), createReqVO.getBlobIds());
        }
        return contract.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateContract(HrmEmployeeContractSaveReqVO updateReqVO) {
        // 1. æ ¡éªŒå­˜åœ¨
        validateContractExists(updateReqVO.getId());
        // 2. æ ¡éªŒå‘˜å·¥å­˜åœ¨
        employeeService.validateEmployee(updateReqVO.getEmployeeId());
        // 3. æ ¡éªŒåˆåŒæ—¥æœŸ
        validateContractDate(updateReqVO);
        // 4. æ ¡éªŒåŒä¸€å‘˜å·¥æ—¶é—´é‡å çš„合同(排除自身)
        validateNoOverlap(updateReqVO.getEmployeeId(), updateReqVO.getStartDate(),
                updateReqVO.getEndDate(), updateReqVO.getId());
        // 5. æ›´æ–°åˆåŒè®°å½•
        HrmEmployeeContractDO updateObj = BeanUtils.toBean(updateReqVO, HrmEmployeeContractDO.class);
        contractMapper.updateById(updateObj);
        // 6. æ›´æ–°é™„ä»¶
        storageAttachmentApi.updateAttachments("file", RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteContract(Long id) {
        // 1. æ ¡éªŒå­˜åœ¨
        validateContractExists(id);
        // 2. åˆ é™¤åˆåŒ
        contractMapper.deleteById(id);
        // 3. åˆ é™¤é™„ä»¶
        storageAttachmentApi.deleteAttachmentsByRecord(RECORD_TYPE, id);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void terminateContract(HrmEmployeeContractTerminateReqVO reqVO) {
        // 1. æ ¡éªŒå­˜åœ¨
        HrmEmployeeContractDO contract = validateContractExists(reqVO.getId());
        // 2. æ ¡éªŒè§£é™¤/终止参数
        Integer terminateStatus = reqVO.getTerminateStatus();
        if (terminateStatus == null
                || (terminateStatus != HrmEmployeeContractMapper.TERMINATE_CANCELED
                && terminateStatus != HrmEmployeeContractMapper.TERMINATE_TERMINATED)
                || reqVO.getTerminateDate() == null || StrUtil.isBlank(reqVO.getTerminateReason())) {
            throw exception(EMPLOYEE_CONTRACT_TERMINATE_REQUIRE_DATE_REASON);
        }
        // 3. å·²è§£é™¤/已终止的合同不允许重复操作
        if (contract.getTerminateStatus() != null
                && contract.getTerminateStatus() != HrmEmployeeContractMapper.TERMINATE_NORMAL) {
            throw exception(EMPLOYEE_CONTRACT_TERMINATE_REQUIRE_DATE_REASON);
        }
        // 4. æ›´æ–°è§£é™¤/终止信息
        HrmEmployeeContractDO updateObj = new HrmEmployeeContractDO();
        updateObj.setId(contract.getId());
        updateObj.setTerminateStatus(terminateStatus);
        updateObj.setTerminateDate(reqVO.getTerminateDate());
        updateObj.setTerminateReason(reqVO.getTerminateReason());
        // 5. è‹¥è§£é™¤/终止的是员工当前合同,清除当前标记
        if (Boolean.TRUE.equals(contract.getIsCurrent())) {
            updateObj.setIsCurrent(false);
        }
        contractMapper.updateById(updateObj);
    }
    @Override
    public HrmEmployeeContractRespVO getContract(Long id) {
        HrmEmployeeContractDO contract = validateContractExists(id);
        HrmEmployeeContractRespVO respVO = BeanUtils.toBean(contract, HrmEmployeeContractRespVO.class);
        // å¡«å……员工信息
        HrmEmployeeDO employee = employeeService.getEmployee(contract.getEmployeeId());
        if (employee != null) {
            Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(Collections.singleton(employee.getDeptId()));
            fillEmployeeInfo(respVO, employee, deptMap);
        }
        // å¡«å……续签上一份合同编号
        fillParentNo(respVO, contract.getParentId());
        // å¡«å……附件
        respVO.setAttachmentList(storageAttachmentApi.listAttachments(RECORD_TYPE, id));
        // åŠ¨æ€è®¡ç®—çŠ¶æ€
        respVO.setStatus(calculateStatus(contract, LocalDate.now()));
        return respVO;
    }
    @Override
    public PageResult<HrmEmployeeContractRespVO> getContractPage(HrmEmployeeContractPageReqVO pageReqVO) {
        LocalDate today = LocalDate.now();
        PageResult<HrmEmployeeContractDO> pageResult = contractMapper.selectPage(pageReqVO, today);
        if (CollUtil.isEmpty(pageResult.getList())) {
            return PageResult.empty();
        }
        // æ‰¹é‡èŽ·å–å‘˜å·¥ä¿¡æ¯
        Set<Long> employeeIds = pageResult.getList().stream()
                .map(HrmEmployeeContractDO::getEmployeeId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, HrmEmployeeDO> employeeMap = CollUtil.isEmpty(employeeIds) ? Collections.emptyMap() :
                employeeService.getEmployeeList(employeeIds).stream()
                        .collect(Collectors.toMap(HrmEmployeeDO::getId, e -> e));
        // æ‰¹é‡èŽ·å–éƒ¨é—¨ä¿¡æ¯
        Set<Long> deptIds = employeeMap.values().stream()
                .map(HrmEmployeeDO::getDeptId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, DeptRespDTO> deptMap = CollUtil.isEmpty(deptIds) ? Collections.emptyMap() : deptApi.getDeptMap(deptIds);
        // æ‰¹é‡èŽ·å–ç»­ç­¾ä¸Šä¸€ä»½åˆåŒä¿¡æ¯
        Set<Long> parentIds = pageResult.getList().stream()
                .map(HrmEmployeeContractDO::getParentId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, HrmEmployeeContractDO> parentMap = CollUtil.isEmpty(parentIds) ? Collections.emptyMap() :
                contractMapper.selectList(HrmEmployeeContractDO::getId, parentIds).stream()
                        .collect(Collectors.toMap(HrmEmployeeContractDO::getId, c -> c));
        // ç»„装 VO
        List<HrmEmployeeContractRespVO> list = pageResult.getList().stream().map(contract -> {
            HrmEmployeeContractRespVO respVO = BeanUtils.toBean(contract, HrmEmployeeContractRespVO.class);
            fillEmployeeInfo(respVO, employeeMap.get(contract.getEmployeeId()), deptMap);
            fillParentNo(respVO, contract.getParentId(), parentMap);
            respVO.setStatus(calculateStatus(contract, today));
            return respVO;
        }).collect(Collectors.toList());
        return new PageResult<>(list, pageResult.getTotal());
    }
    @Override
    public Long getExpiringCount() {
        return contractMapper.selectCountByExpiring(LocalDate.now());
    }
    private HrmEmployeeContractDO validateContractExists(Long id) {
        HrmEmployeeContractDO contract = contractMapper.selectById(id);
        if (contract == null) {
            throw exception(EMPLOYEE_CONTRACT_NOT_EXISTS);
        }
        return contract;
    }
    /**
     * æ ¡éªŒåŒä¸€å‘˜å·¥æ—¶é—´é‡å çš„合同,避免一个员工同时存在多份未解除/未终止的生效合同
     */
    private void validateNoOverlap(Long employeeId, LocalDate startDate, LocalDate endDate, Long excludeId) {
        List<HrmEmployeeContractDO> overlaps = contractMapper.selectOverlapping(employeeId, startDate, endDate, excludeId);
        if (CollUtil.isNotEmpty(overlaps)) {
            throw exception(EMPLOYEE_CONTRACT_DATE_OVERLAP);
        }
    }
    /**
     * å¡«å……续签上一份合同编号
     */
    private void fillParentNo(HrmEmployeeContractRespVO respVO, Long parentId) {
        if (parentId != null) {
            HrmEmployeeContractDO parent = contractMapper.selectById(parentId);
            if (parent != null) {
                respVO.setParentNo(parent.getContractNo());
            }
        }
    }
    /**
     * æ‰¹é‡å¡«å……续签上一份合同编号
     */
    private void fillParentNo(HrmEmployeeContractRespVO respVO, Long parentId, Map<Long, HrmEmployeeContractDO> parentMap) {
        if (parentId != null) {
            HrmEmployeeContractDO parent = parentMap.get(parentId);
            if (parent != null) {
                respVO.setParentNo(parent.getContractNo());
            }
        }
    }
    private void validateContractDate(HrmEmployeeContractSaveReqVO reqVO) {
        // éžæ— å›ºå®šæœŸé™åˆåŒï¼ˆæœŸé™ç±»åž‹ != 2)必须填写结束日期
        if (!Integer.valueOf(2).equals(reqVO.getContractTermType()) && reqVO.getEndDate() == null) {
            throw exception(EMPLOYEE_CONTRACT_END_DATE_REQUIRE_FOR_FIXED);
        }
        // å¼€å§‹æ—¥æœŸä¸èƒ½æ™šäºŽç»“束日期
        if (reqVO.getStartDate() != null && reqVO.getEndDate() != null
                && reqVO.getStartDate().isAfter(reqVO.getEndDate())) {
            throw exception(EMPLOYEE_CONTRACT_DATE_INVALID);
        }
        // è¯•用期开始日期不能晚于试用期结束日期
        if (reqVO.getProbationStartDate() != null && reqVO.getProbationEndDate() != null
                && reqVO.getProbationStartDate().isAfter(reqVO.getProbationEndDate())) {
            throw exception(EMPLOYEE_CONTRACT_DATE_INVALID);
        }
    }
    /**
     * åŠ¨æ€è®¡ç®—åˆåŒçŠ¶æ€
     *
     * @param contract åˆåŒè®°å½•
     * @param today å½“前日期
     * @return çŠ¶æ€ï¼š1-待生效 2-生效中 3-即将到期 4-已到期 5-已解除 6-已终止
     */
    private Integer calculateStatus(HrmEmployeeContractDO contract, LocalDate today) {
        Integer terminateStatus = contract.getTerminateStatus();
        if (terminateStatus != null && terminateStatus == HrmEmployeeContractMapper.TERMINATE_CANCELED) {
            return HrmEmployeeContractPageReqVO.STATUS_CANCELED;
        }
        if (terminateStatus != null && terminateStatus == HrmEmployeeContractMapper.TERMINATE_TERMINATED) {
            return HrmEmployeeContractPageReqVO.STATUS_TERMINATED;
        }
        LocalDate endDate = contract.getEndDate();
        if (endDate != null && endDate.isBefore(today)) {
            return HrmEmployeeContractPageReqVO.STATUS_EXPIRED;
        }
        LocalDate startDate = contract.getStartDate();
        if (startDate != null && startDate.isAfter(today)) {
            return HrmEmployeeContractPageReqVO.STATUS_PENDING;
        }
        if (endDate != null && !endDate.isAfter(today.plusDays(HrmEmployeeContractMapper.REMIND_DAYS))) {
            return HrmEmployeeContractPageReqVO.STATUS_EXPIRING_SOON;
        }
        return HrmEmployeeContractPageReqVO.STATUS_ACTIVE;
    }
    private void fillEmployeeInfo(HrmEmployeeContractRespVO respVO, HrmEmployeeDO employee,
                                  Map<Long, DeptRespDTO> deptMap) {
        if (employee != null) {
            respVO.setEmployeeName(employee.getName());
            respVO.setEmployeeNo(employee.getEmployeeNo());
            if (employee.getDeptId() != null && deptMap != null) {
                DeptRespDTO dept = deptMap.get(employee.getDeptId());
                if (dept != null) {
                    respVO.setDeptName(dept.getName());
                }
            }
        }
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -35,6 +35,7 @@
    // ===== ERP é‡‡è´­ =====
    ERP_PURCHASE_REQUEST("erp_purchase_request", "ERP é‡‡è´­ç”³è¯·"),
    ERP_PURCHASE_ORDER("erp_purchase_order", "ERP é‡‡è´­è®¢å•"),
    ERP_PURCHASE_INVOICE("erp_purchase_invoice", "ERP é‡‡è´­æ¥ç¥¨"),
    // ===== ERP é”€å”® =====
    ERP_SALE_ORDER("erp_sale_order", "ERP é”€å”®è®¢å•"),