From 9fceeaad9af96bc58ba714560814c6e94f8af406 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 10 八月 2026 17:13:01 +0800
Subject: [PATCH] feat(crm): 增强合同开票与回款功能

---
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java             |    6 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java                                         |   16 
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java                      |   96 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/common/ErpBizTypeEnum.java                                      |    1 
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/employee/HrmEmployeeContractDO.java                    |  141 ++
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java                          |    8 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java           |  234 ++++
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java                         |   21 
 docs/hrm_employee_contract_integration.md                                                                                        |  182 +++
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java          |    6 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java |   45 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java                       |  119 ++
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmInvoiceParseFunction.java                |   44 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoicePageReqVO.java |   30 
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractTerminateReqVO.java   |   28 
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java                  |   17 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java                      |   51 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/listener/ErpPurchaseInvoiceStatusListener.java       |   43 
 docs/purchase_invoice_payment_link.md                                                                                            |  155 ++
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java    |    7 
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java              |   11 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java                                       |    5 
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java                                |    2 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentRespVO.java      |    6 
 yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java                        |    1 
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java                                         |    6 
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractRespVO.java           |   94 +
 docs/receivable_invoice_link.md                                                                                                  |  119 ++
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/HrmNoRedisDAO.java                                          |   15 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpFinancePaymentDO.java                       |    7 
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractService.java                      |   68 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java                   |  248 ++++
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeContractController.java          |   84 +
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java                               |    5 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpFinancePaymentController.java             |   30 
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java                            |    8 
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractPageReqVO.java        |   68 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceDO.java                     |   82 +
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeContractMapper.java                     |  190 +++
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractSaveReqVO.java        |   73 +
 sql/mysql/hrm_employee_contract_init.sql                                                                                         |  100 +
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java                        |    7 
 yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractServiceImpl.java                  |  321 +++++
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java    |   94 +
 docs/contract_invoice_amount.md                                                                                                  |   62 +
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java |    5 
 yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java                                     |    5 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentSaveReqVO.java   |    4 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentService.java                         |   10 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseInvoiceAuditStatusEnum.java                          |   34 
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentServiceImpl.java                     |   74 +
 yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java                        |   39 
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java                    |   55 +
 53 files changed, 3,139 insertions(+), 43 deletions(-)

diff --git a/docs/contract_invoice_amount.md b/docs/contract_invoice_amount.md
new file mode 100644
index 0000000..8ac6c03
--- /dev/null
+++ b/docs/contract_invoice_amount.md
@@ -0,0 +1,62 @@
+# 鍚堝悓绠$悊鏂板"宸插紑绁ㄩ噾棰�/鏈紑绁ㄩ噾棰�"瀛楁 - 鍓嶇鑱旇皟鏂规
+
+## 娑夊強椤甸潰
+
+- 鍚堝悓绠$悊鍒楄〃椤�
+- 鍚堝悓璇︽儏椤�
+- 鍒涘缓寮�绁� / 鍒涘缓鍥炴鏃堕�夋嫨鍚堝悓鐨勪笅鎷夛紙simple-list锛�
+
+## API
+
+鏈**涓嶆敼鍔ㄤ换浣曟帴鍙h矾寰勩�佹柟娉曘�佽姹傚弬鏁�**锛屼粎鍚堝悓鐩稿叧鎺ュ彛鐨�**鍝嶅簲缁撴瀯鏂板涓や釜瀛楁**銆�
+
+| 鏂规硶 | 璺緞 | 璇存槑 |
+|------|------|------|
+| 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`锛屼粎鍒楄〃/璇︽儏鎺ュ彛杩斿洖锛宻imple-list 涓嶈繑鍥� |
+
+**鍝嶅簲绀轰緥锛堝垪琛�/璇︽儏锛夛細**
+
+```json
+{
+  "code": 0,
+  "data": {
+    "totalPrice": 1000000.00,
+    "totalReceivablePrice": 400000.00,
+    "totalInvoicePrice": 1000000.00,
+    "unInvoicePrice": 0.00
+  }
+}
+```
+
+## 瀛楁灞曠ず瑙勫垯
+
+| 瀛楁 | 灞曠ず浣嶇疆 | 璇存槑 |
+|------|----------|------|
+| 宸插紑绁ㄩ噾棰濓紙totalInvoicePrice锛� | 鍒楄〃琛ㄦ牸鍒椼�佽鎯呴〉閲戦鍖哄煙 | 鍚鎵逛腑鐨勫紑绁ㄥ崟閲戦锛屼笌鍚庣闃茶秴鏍¢獙鍙e緞涓�鑷� |
+| 鏈紑绁ㄩ噾棰濓紙unInvoicePrice锛� | 鍒楄〃琛ㄦ牸鍒椼�佽鎯呴〉閲戦鍖哄煙 | 鍚堝悓閲戦 - 宸插紑绁ㄩ噾棰濓紝鍙悓鏃剁敤浜庡睍绀�"鍓╀綑鍙紑绁ㄩ噾棰�" |
+
+## 涓氬姟瑙勫垯璇存槑
+
+| 鍦烘櫙 | 瑙勫垯 |
+|------|------|
+| 瀹℃壒涓殑寮�绁ㄥ崟 | 璁″叆"宸插紑绁ㄩ噾棰�"锛屽崰鐢ㄥ悎鍚岄搴︼紙涓庡悗绔彁浜ゅ紑绁ㄦ椂鐨勬牎楠屼竴鑷达紝鍓嶇灞曠ず鐨勫墿浣欓搴︽墠鍑嗙‘锛� |
+| 瀹℃壒琚┏鍥� / 鍙栨秷鐨勫紑绁ㄥ崟 | 涓嶈鍏�"宸插紑绁ㄩ噾棰�"锛岄搴﹁嚜鍔ㄩ噴鏀� |
+| 鏈紑绁ㄩ噾棰� | 鑻ヤ负璐熸暟璇存槑宸茶秴寮�锛堟甯告祦绋嬪悗绔凡鎷︽埅锛屼笉浼氬嚭鐜帮級 |
+| 鍚堝悓涓嬫媺锛坰imple-list锛� | 杩斿洖 totalInvoicePrice锛屽彲鐢ㄤ簬鍦ㄥ垱寤哄紑绁�/鍥炴鏃舵彁绀哄墿浣欏彲寮�閲戦 |
+
+## 娉ㄦ剰浜嬮」
+
+- 瀛楁璇箟鏄�"鍚鎵逛腑"锛屼笉鏄�"瀹為檯宸插紑鍑虹エ"閲戦锛屽墠绔睍绀烘椂鏂囨寤鸿鐢�"宸插紑绁ㄩ噾棰濓紙鍚鎵逛腑锛�"閬垮厤姝т箟銆�
+- 鏂板瀛楁浠呯敤浜庡睍绀猴紝鍓嶇**涓嶈**鎻愪氦杩欎袱涓瓧娈碉紙鍚庣涓嶆帴鏀讹紝灞炲彧璇绘眹鎬诲瓧娈碉級銆�
+- Excel 瀵煎嚭鏈鏈柊澧炶繖涓ゅ垪锛屽闇�瀵煎嚭鍙彟琛岀敵璇枫��
diff --git a/docs/hrm_employee_contract_integration.md b/docs/hrm_employee_contract_integration.md
new file mode 100644
index 0000000..3820072
--- /dev/null
+++ b/docs/hrm_employee_contract_integration.md
@@ -0,0 +1,182 @@
+# 鍛樺伐鍚堝悓绠$悊 - 鍓嶇鑱旇皟鏂规
+
+## 娑夊強椤甸潰
+
+- 鏂板銆屽憳宸ュ悎鍚屻�嶇鐞嗛〉锛歚views/hrm/employee/contract/index.vue`
+- 鏂板銆屽憳宸ュ悎鍚屻�嶈彍鍗曪紙宸查厤缃悗绔彍鍗曪紝鎸傝浇鍦� HRM 鈫� 浜哄姏璧勬簮 涓嬨�佷笌銆屽憳宸ョ鐞嗐�嶅钩绾э紝component = `hrm/employee/contract/index`锛涗笉瑕佹寕鍒般�屽憳宸ョ鐞嗐�嶄笅锛屽惁鍒欒鑿滃崟浼氬彉鎴愮洰褰曞鑷村憳宸ュ垪琛ㄩ〉鏃犳硶鐩磋揪锛�
+- 閫夋嫨鍛樺伐涓嬫媺锛氬鐢ㄧ幇鏈夊憳宸ョ簿绠�鍒楄〃鎺ュ彛
+- 鍛樺伐璇︽儏椤碉細鏂板銆屽憳宸ュ悎鍚屻�峊ab锛屽睍绀鸿鍛樺伐绛捐鐨勫叏閮ㄥ悎鍚岋紙澶嶇敤 `/hrm/employee-contract/page`锛屾寜 `employeeId` 杩囨护锛�
+
+## 涓氬姟娴佺▼涓庢暟鎹甫鍏�
+
+1. 鍒涘缓鍚堝悓锛氬憳宸ヤ粠 `/hrm/employee/simple-list` 涓嬫媺閫夋嫨 鈫� 鑷姩甯﹀叆鍛樺伐锛堟棤闇�鍐嶉�夐儴闂�/宀椾綅锛�
+2. 缁鍚堝悓锛氬悎鍚屽垪琛ㄦ搷浣滃垪銆岀画绛俱�嶆寜閽� 鈫� 鑷姩甯﹀叆璇ュ憳宸� + 涓婁唤鍚堝悓鐨勭被鍨�/鏈熼檺/绛剧害涓讳綋/宸ヨ祫锛屾棩鏈熼渶閲嶆柊濉啓锛涙彁浜ゆ椂鏄惧紡浼� `parentId` 鎸囧悜琚画绛惧悎鍚�
+3. 鍚堝悓鍒楄〃锛氭寜鍚堝悓缂栧彿銆佸憳宸ャ�佸悎鍚岀被鍨嬨�佹湡闄愮被鍨嬨�佸悎鍚岀姸鎬併�佸埌鏈熺被鍨嬬瓫閫�
+3. 瑙i櫎/缁堟锛氫粎瀵�"寰呯敓鏁�/鐢熸晥涓�/鍗冲皢鍒版湡"鐨勫悎鍚屽彲鎿嶄綔锛屽脊鍑虹‘璁ゆ濉啓瑙i櫎鏃ユ湡涓庡師鍥�
+4. 鍒版湡鎻愰啋锛氬垪琛ㄦ敮鎸併�屽嵆灏嗗埌鏈� / 宸插埌鏈熴�嶅揩鎹风瓫閫夛紱HRM 棣栭〉鍙皟鐢� `expiring-count` 灞曠ず鍗冲皢鍒版湡鍚堝悓鏁伴噺
+
+## API
+
+| 鏂规硶 | 璺緞 | 璇存槑 |
+|------|------|------|
+| POST | /hrm/employee-contract/create | 鏂板鍚堝悓 |
+| PUT | /hrm/employee-contract/update | 淇敼鍚堝悓 |
+| PUT | /hrm/employee-contract/terminate | 瑙i櫎/缁堟鍚堝悓 |
+| DELETE | /hrm/employee-contract/delete | 鍒犻櫎鍚堝悓 |
+| GET | /hrm/employee-contract/get | 鍚堝悓璇︽儏锛堝惈闄勪欢锛� |
+| GET | /hrm/employee-contract/page | 鍚堝悓鍒嗛〉 |
+| GET | /hrm/employee-contract/expiring-count | 鍗冲皢鍒版湡鍚堝悓鏁伴噺锛堜緵寰呭姙/棣栭〉鎻愰啋锛� |
+
+### 鍒涘缓/淇敼鍚堝悓
+
+**璇锋眰鍙傛暟锛坈reate/update 鍏辩敤锛夛細**
+
+| 鍙傛暟 | 绫诲瀷 | 蹇呭~ | 璇存槑 |
+|------|------|------|------|
+| id | Long | 鍚� | 鍚堝悓ID锛堜慨鏀规椂蹇呬紶锛� |
+| employeeId | Long | 鏄� | 鍛樺伐ID |
+| parentId | Long | 鍚� | 缁涓婁竴浠藉悎鍚孖D銆�**缁鎸夐挳**鐐瑰嚮鏃跺墠绔細浼狅紙鎸囧悜琚画绛剧殑鍚堝悓锛夛紱鏅�氭柊澧炰笉浼狅紝鍚庣鑷姩鍏宠仈璇ュ憳宸ユ渶杩戜竴浠藉悎鍚� |
+| 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
+}
+```
+
+### 瑙i櫎/缁堟鍚堝悓
+
+**璇锋眰鍙傛暟锛�**
+
+| 鍙傛暟 | 绫诲瀷 | 蹇呭~ | 璇存槑 |
+|------|------|------|------|
+| id | Long | 鏄� | 鍚堝悓ID |
+| terminateStatus | Integer | 鏄� | 1-宸茶В闄� 2-宸茬粓姝� |
+| terminateDate | date | 鏄� | 瑙i櫎/缁堟鏃ユ湡 |
+| terminateReason | String | 鏄� | 瑙i櫎/缁堟鍘熷洜 |
+
+**鍝嶅簲锛�** `{ "code": 0, "data": true }`
+
+### 鍚堝悓鍒嗛〉
+
+**璇锋眰鍙傛暟锛堢瓫閫夛級锛�**
+
+| 鍙傛暟 | 绫诲瀷 | 璇存槑 |
+|------|------|------|
+| pageNo / pageSize | Integer | 鍒嗛〉鍙傛暟 |
+| contractNo | String | 鍚堝悓缂栧彿锛堟ā绯婂尮閰嶏級 |
+| employeeId | Long | 鍛樺伐ID |
+| contractType | Integer | 鍚堝悓绫诲瀷 |
+| contractTermType | Integer | 鏈熼檺绫诲瀷 |
+| terminateStatus | Integer | 0-姝e父 1-宸茶В闄� 2-宸茬粓姝� |
+| status | Integer | 鍚堝悓鐘舵�侊紙鍔ㄦ�佽绠楋級锛�1-寰呯敓鏁� 2-鐢熸晥涓� 3-鍗冲皢鍒版湡 4-宸插埌鏈� 5-宸茶В闄� 6-宸茬粓姝� |
+| expiryType | Integer | 鍒版湡绫诲瀷锛�1-鍗冲皢鍒版湡 2-宸插埌鏈燂紙寰呭姙鎻愰啋鐢紝涓� status 浜掓枼锛� |
+
+**鍝嶅簲瀛楁鏂板锛�**
+
+| 瀛楁 | 绫诲瀷 | 璇存槑 |
+|------|------|------|
+| contractNo | String | 鍚堝悓缂栧彿锛堣嚜鍔ㄧ敓鎴愶級 |
+| parentId | Long | 缁涓婁竴浠藉悎鍚孖D |
+| parentNo | String | 缁涓婁竴浠藉悎鍚岀紪鍙凤紙鍓嶇灞曠ず"缁鑷�"锛� |
+| isCurrent | Boolean | 鏄惁鍛樺伐褰撳墠鐢熸晥鍚堝悓锛坱rue 鏃跺墠绔墦鏍囷級 |
+| 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 |
+| 绛捐鏃ユ湡 | 鉁� | 鏃ユ湡 | 鉁� | |
+| 寮�濮�/缁撴潫鏃ユ湡 | 鉁� | 鏃ユ湡 | 鉁� | 鏃犲浐瀹氭湡闄愮粨鏉熸棩鏈熷彲绌� |
+| 璇曠敤鏈熻捣姝� | 鉁� | 鏃ユ湡 | 鉁� | |
+| 璇曠敤鏈�/杞宸ヨ祫 | 鉁� | 鏁板瓧 | 鉁� | |
+| 鍚堝悓鐘舵�� | 鉁� 鏍囩 | - | 鉁� | 鍔ㄦ�佽绠楋紝鎸夐鑹插尯鍒嗭細鐢熸晥涓�(缁�)/鍗冲皢鍒版湡(姗�)/宸插埌鏈�(鐏�)/宸茶В闄ぢ峰凡缁堟(绾�)/寰呯敓鏁�(钃�) |
+| 闄勪欢 | - | 涓婁紶 | 鉁� | 璧扮郴缁熼檮浠朵笂浼� |
+| 鎿嶄綔 | 鉁� | - | - | 缂栬緫 / 瑙i櫎/缁堟 / 鍒犻櫎 |
+
+## 闄勪欢瀵规帴娴佺▼
+
+1. 涓婁紶锛氳皟鐢� `POST /system/storage-blob/upload` 鑾峰彇 blobId
+2. 淇濆瓨鍚堝悓锛氳〃鍗� `blobIds` 瀛楁浼� blobId 鏁扮粍锛屽悗绔粦瀹�
+3. 鏌ヨ锛氳鎯呮帴鍙h繑鍥� `attachmentList`锛涘垪琛ㄩ〉濡傞渶鍗曠嫭鍒锋柊闄勪欢锛屽彲璋冪敤 `GET /system/storage-attachment/list?recordType=hrm_employee_contract&recordId=xxx`
+4. 涓氬姟琛ㄤ笉瀛樻枃浠跺湴鍧�锛岀姝㈠湪鍓嶇璋冪敤 `/infra/file/*` 鏃ф帴鍙�
+
+## 涓氬姟瑙勫垯璇存槑
+
+> **澶氬悎鍚岃璁�**锛氫竴涓憳宸ュ彲绛捐澶氭鍚堝悓锛堢画绛�/鎹㈢锛夈�傛暟鎹〃閫氳繃 `parent_id`锛堢画绛句笂涓�浠藉悎鍚孖D锛夊舰鎴愮画绛鹃摼锛岄�氳繃 `is_current`锛堟槸鍚﹀綋鍓嶇敓鏁堝悎鍚岋級鏍囪瘑鍛樺伐褰撳墠鍚堝悓锛涘悓涓�鍛樺伐浠绘剰鏃跺埢浠呬竴浠� `is_current=true` 鐨勫悎鍚屻��
+
+| 鍦烘櫙 | 瑙勫垯 |
+|------|------|
+| 鐘舵�佽绠� | 宸茶В闄�(5)/宸茬粓姝�(6) > 宸插埌鏈�(4) > 寰呯敓鏁�(1) > 鍗冲皢鍒版湡(3) > 鐢熸晥涓�(2)锛屽悗绔寜褰撳墠鏃ユ湡鍔ㄦ�佽绠楋紝鍓嶇鏃犻渶缁存姢 |
+| 鍗冲皢鍒版湡 | 缁撴潫鏃ユ湡鍦ㄥ綋澶╄捣鐨� 30 澶╁唴锛堝惈褰撳ぉ锛変笖鏈В闄�/缁堟 |
+| 缁鍏宠仈 | 鎿嶄綔鍒椼�岀画绛俱�嶆寜閽偣鍑绘椂鍓嶇鏄惧紡浼� `parent_id` 鎸囧悜琚画绛惧悎鍚岋紱鏅�氭柊澧炰笉浼狅紝鍚庣鑷姩鎶婅鍛樺伐鏈�杩戜竴浠藉悎鍚屼綔涓� `parent_id`锛堢画绛鹃摼锛夈�傚墠绔垪琛�/璇︽儏灞曠ず"缁鑷� xxx" |
+| 褰撳墠鍚堝悓 | 鍒涘缓鏂板悎鍚屾椂锛屽悗绔嚜鍔ㄦ竻闄よ鍛樺伐鏃у悎鍚岀殑 `is_current` 骞剁疆鏂板悎鍚屼负 true锛涜В闄�/缁堟褰撳墠鍚堝悓鏃惰嚜鍔ㄦ竻闄ゆ爣璁� |
+| 鏃堕棿閲嶅彔 | 鍚庣寮哄埗鏍¢獙锛氬悓涓�鍛樺伐涓嶈兘瀛樺湪鏃堕棿閲嶅彔鐨勬湭瑙i櫎/鏈粓姝㈠悎鍚岋紙鏃犲浐瀹氭湡闄愯涓烘棤闄愰暱锛夛紝鎶ラ敊"璇ュ憳宸ュ湪鎵�閫夋椂闂存宸插瓨鍦ㄧ敓鏁堝悎鍚�"锛岄渶鍏堣В闄�/缁堟鏃у悎鍚屾垨璋冩暣鏃ユ湡 |
+| 瑙i櫎/缁堟 | 浠呮湭瑙i櫎/鏈粓姝㈢殑鍚堝悓鍙搷浣滐紱宸茶В闄�/宸茬粓姝㈠悎鍚屼笉鍙噸澶嶆搷浣滐紱鎿嶄綔鍚庣姸鎬佸浐瀹氫负宸茶В闄�/宸茬粓姝� |
+| 缂栬緫闄愬埗 | 宸茶В闄�/宸茬粓姝㈢殑鍚堝悓寤鸿鍙灞曠ず锛屼笉鍏佽淇敼璧锋鏃ユ湡锛堝悗绔湭寮哄埗锛屽墠绔彁绀哄嵆鍙級 |
+| 鍒犻櫎 | 鐩存帴鍒犻櫎璁板綍骞剁骇鑱斿垹闄ら檮浠� |
+| 鍒版湡鎻愰啋 | 鍒楄〃銆屽嵆灏嗗埌鏈熴�嶇瓫閫� = status=3 鎴� expiryType=1锛涢椤靛彲閫氳繃 expiring-count 灞曠ず鏁伴噺 |
+| 鍛樺伐缁村害 | 鍚堝悓椤垫寜鍛樺伐绛涢�夊彲鏌ョ湅鏌愬憳宸ュ叏閮ㄥ悎鍚岋紱鍛樺伐璇︽儏椤点�屽憳宸ュ悎鍚屻�峊ab 灞曠ず璇ュ憳宸ュ叏閮ㄥ悎鍚屽苟鏍囪褰撳墠鍚堝悓 |
+
+## 娉ㄦ剰浜嬮」
+
+- 鍚堝悓缂栧彿鍞竴锛岀敱鍚庣鐢熸垚锛屽墠绔笉鑳戒慨鏀�
+- 鏂板/缂栬緫鍚堝悓鐨勬牎楠岋紙鍥哄畾鏈熼檺蹇呴』濉粨鏉熸棩鏈熴�佹棩鏈熷厛鍚庯級鐢卞悗绔繑鍥炴槑纭敊璇彁绀猴紝鍓嶇琛ㄥ崟鏍¢獙鍙悓姝ュ姞涓�
+- 鍒楄〃鐘舵�佸垪闇�鏍规嵁 status 鍊兼槧灏勫瓧鍏� hrm_contract_status 灞曠ず涓枃涓庨鑹�
+- 瑙i櫎/缁堟鎿嶄綔闇�浜屾纭锛岄伩鍏嶈鎿嶄綔
+- 鍒嗛〉鏌ヨ闄勪欢鍒楄〃涓虹┖鏃朵笉杩斿洖璇ュ瓧娈碉紝鍓嶇闇�鍋氱┖鍊煎吋瀹�
diff --git a/docs/purchase_invoice_payment_link.md b/docs/purchase_invoice_payment_link.md
new file mode 100644
index 0000000..fa6e939
--- /dev/null
+++ b/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銆乮nvoiceNo銆乮nvoiceTitle銆乸rice銆乤uditStatus 绛夛級 |
+
+**鍝嶅簲绀轰緥锛�**
+
+```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 鍥炶皟鏇存柊锛屽墠绔棤闇�澶勭悊锛屽垎椤垫帴鍙g洿鎺ヨ繑鍥炴渶鏂扮姸鎬併��
+- 鏉ョエ鍙戠エ闄勪欢涓婁紶浣跨敤 system 妯″潡 Storage API锛坲pload -> bind锛夛紝涓嶈浣跨敤鏃х増 /infra/file 鎺ュ彛銆�
diff --git a/docs/receivable_invoice_link.md b/docs/receivable_invoice_link.md
new file mode 100644
index 0000000..a3f65bc
--- /dev/null
+++ b/docs/receivable_invoice_link.md
@@ -0,0 +1,119 @@
+# 寮�绁ㄤ笌鍥炴鍏宠仈锛堝厛寮�绁ㄥ悗鍥炴锛� - 鍓嶇鑱旇皟鏂规
+
+## 娑夊強椤甸潰
+
+- 鍥炴绠$悊鍒楄〃椤�
+- 鍥炴鍒涘缓 / 缂栬緫琛ㄥ崟
+- 鍥炴璇︽儏椤�
+- 寮�绁ㄧ鐞嗗垪琛ㄩ〉銆佸紑绁ㄨ鎯呴〉锛�**鍓嶇鐩墠缂哄け锛岄渶鏂板缓**锛�
+- 鍚堝悓绠$悊鍒楄〃椤点�佸悎鍚岃鎯呴〉锛堜笂涓换鍔″凡鎺ュ叆"宸插紑绁ㄩ噾棰�/鏈紑绁ㄩ噾棰�"锛屾湰娆′笉娑夊強锛�
+
+## 涓氬姟娴佺▼涓庢暟鎹甫鍏�
+
+1. **寮�绁� 鈫� 鍥炴**锛氬紑绁ㄥ崟瀹℃壒閫氳繃 + 涓婁紶鍙戠エ闄勪欢鍚庯紝鎵嶄細鍑虹幇鍦ㄥ洖娆惧垱寤鸿〃鍗曠殑"鍏宠仈寮�绁�"涓嬫媺涓��
+2. **鍥炴鍒涘缓**锛氶�夋嫨瀹㈡埛 鈫� 閫夋嫨鍚堝悓 鈫� 閫夋嫨鍏宠仈寮�绁紙鑷姩甯﹀叆瀹㈡埛銆佸悎鍚岋級鈫掞紙鍙�夛級閫夋嫨鍥炴璁″垝 鈫� 濉洖娆鹃噾棰濓紙鍙濉负璇ュ紑绁ㄧ殑鍓╀綑鍙洖娆鹃噾棰濓級銆�
+3. **鍚堝悓涓嬫媺锛坰imple-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`锛圔oolean锛夛細鏄惁宸蹭笂浼犲彂绁ㄩ檮浠讹紝鐢ㄤ簬鍒楄〃蹇�熷垽鏂摢浜涘紑绁ㄥ彲琚洖娆惧叧鑱斻��
+- `remainingInvoicePrice`锛圔igDecimal锛夛細鍓╀綑鍙洖娆鹃噾棰� = 寮�绁ㄩ噾棰� - 璇ュ紑绁ㄥ凡鍥炴閲戦锛堢粺璁¤崏绋� + 瀹℃壒涓� + 瀹℃壒閫氳繃鐨勫洖娆撅級銆�
+
+## 瀛楁灞曠ず瑙勫垯
+
+| 瀛楁 | 灞曠ず浣嶇疆 | 璇存槑 |
+|------|----------|------|
+| 鍏宠仈寮�绁� | 鍥炴鍒楄〃鍒椼�佸洖娆捐鎯呴〉 | 灞曠ず `invoice.no` / `invoice.invoiceNo` / `invoice.price` |
+| 鍏宠仈寮�绁ㄩ�夋嫨鍣� | 鍥炴鍒涘缓/缂栬緫琛ㄥ崟 | 閫夋嫨鍚庡甫鍏ュ鎴枫�佸悎鍚屻�侀濉洖娆鹃噾棰濅负 `remainingInvoicePrice` |
+| 鍓╀綑鍙洖娆鹃噾棰� | 寮�绁ㄥ垪琛ㄥ垪銆佸紑绁ㄨ鎯呴〉 | 寮�绁ㄩ噾棰� - 璇ュ紑绁ㄥ凡鍥炴閲戦 |
+| 鏄惁宸蹭笂浼犻檮浠� | 寮�绁ㄥ垪琛ㄥ垪 | 杈呭姪鍒ゆ柇寮�绁ㄦ槸鍚﹀彲鍥炴锛堥渶瀹℃壒閫氳繃 + 宸蹭笂浼犻檮浠讹級 |
+
+## 涓氬姟瑙勫垯璇存槑
+
+| 鍦烘櫙 | 瑙勫垯 |
+|------|------|
+| 瀹℃壒涓殑寮�绁� | 涓嶅彲閫変负鍥炴渚濇嵁锛堝悗绔嫤鎴細鍏宠仈寮�绁ㄤ笉鏄鎵归�氳繃鐘舵�侊級 |
+| 鏈笂浼犲彂绁ㄩ檮浠剁殑寮�绁� | 涓嶅彲閫変负鍥炴渚濇嵁锛堝悗绔嫤鎴細鍏宠仈寮�绁ㄦ湭涓婁紶鍙戠エ闄勪欢锛� |
+| 宸插叏閮ㄥ洖娆剧殑寮�绁� | 浠�"鍏宠仈寮�绁�"涓嬫媺杩囨护锛堝墿浣欏彲鍥炴 鈮� 0锛� |
+| 鍥炴閲戦瓒呴檺 | 鍚庣鎷︽埅锛氬洖娆鹃噾棰濊秴鍑鸿寮�绁ㄥ墿浣欏彲鍥炴閲戦 |
+| 寮�绁ㄤ笌鍚堝悓涓嶅尮閰� | 鍚庣鎷︽埅锛氭墍閫夊紑绁ㄤ笌鍥炴鍚堝悓涓嶄竴鑷� |
+| 缂栬緫鍥炴鏃舵敼寮�绁� | 鑽夌/瀹℃壒涓姸鎬佸彲淇敼鍏宠仈寮�绁紝鏀瑰悗閲嶆柊鎸夋柊寮�绁ㄦ牎楠岄噾棰� |
+
+## 鍓嶇缂哄彛锛堥渶鏂板缓锛�
+
+> 鍚庣寮�绁ㄦā鍧楀凡瀹屾暣锛堝惈瀹℃壒娴佺▼銆侀檮浠朵笂浼狅級锛屼絾鍓嶇灏氭棤寮�绁ㄩ〉闈笌 API锛岄渶鍓嶇鏂板缓銆�
+
+1. **寮�绁ㄧ鐞嗛〉**锛歚views/crm/invoice/`锛堝垪琛ㄣ�佸垱寤�/缂栬緫銆佸鎵规彁浜ゃ�佸彂绁ㄩ檮浠朵笂浼犱笌灞曠ず锛夈��
+2. **寮�绁� API**锛歚api/crm/invoice/`锛坧age / 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` 涓虹┖锛夛紝缂栬緫鏃朵細琚姹傞噸鏂伴�夋嫨鍚堟牸寮�绁ㄣ��
+- 寮�绁ㄥ垪琛ㄥ睍绀�"鍓╀綑鍙洖娆鹃噾棰�"鏃讹紝鏂囨寤鸿鐢�"鍓╀綑鍙洖娆鹃噾棰濓紙鍚鎵逛腑锛�"閬垮厤姝т箟锛屼笌鍚堝悓"宸插紑绁ㄩ噾棰濓紙鍚鎵逛腑锛�"鍙e緞涓�鑷淬��
diff --git a/sql/mysql/hrm_employee_contract_init.sql b/sql/mysql/hrm_employee_contract_init.sql
new file mode 100644
index 0000000..d480d96
--- /dev/null
+++ b/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 '缁涓婁竴浠藉悎鍚孖D锛堜竴涓憳宸ュ彲绛捐澶氭鍚堝悓锛岄�氳繃璇ュ瓧娈靛舰鎴愮画绛鹃摼锛�',
+    `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 '瑙i櫎/缁堟鐘舵�侊細0-姝e父锛�1-宸茶В闄わ紝2-宸茬粓姝�',
+    `terminate_date` date DEFAULT NULL COMMENT '瑙i櫎/缁堟鏃ユ湡',
+    `terminate_reason` varchar(255) DEFAULT NULL COMMENT '瑙i櫎/缁堟鍘熷洜',
+    `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 '缁涓婁竴浠藉悎鍚孖D' 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, '瑙i櫎缁堟鐘舵��', '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 瑙i櫎缁堟鐘舵�� 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, '姝e父', '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');
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
index 4de695d..d78cba5 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
+++ b/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銆乶ame 绛夌簿绠�瀛楁
                 .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")
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java
index 4410428..e9acd92 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java
+++ b/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")
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java
index 0a960e5..8397ad9 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/CrmInvoiceController.java
+++ b/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);
+    }
+
 }
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java
index 05b1e68..57f86c5 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/invoice/vo/invoice/CrmInvoiceRespVO.java
+++ b/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;
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java
index 58e02d1..e3c6ba8 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java
+++ b/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)));
         });
     }
 
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java
index 75827db..aab6fc4 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java
+++ b/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")
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java
index 8ab85da..50d5d56 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java
+++ b/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;
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java
index c24614a..6d4a2d0 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java
+++ b/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;
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java
index a5a814f..9dcbca0 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/invoice/CrmInvoiceMapper.java
+++ b/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));
         // 鑾峰緱閲戦
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
index 433648b..496afea 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
+++ b/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();
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
index 835b74a..d514bbe 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
+++ b/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, "鍒涘缓鍥炴澶辫触锛屽師鍥狅細鍥炴閲戦瓒呭嚭璇ュ紑绁ㄥ墿浣欏彲鍥炴閲戦锛岀洰鍓嶅彲鍥炴锛歿} 鍏�");
 
 }
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmInvoiceParseFunction.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmInvoiceParseFunction.java
new file mode 100644
index 0000000..d2a7600
--- /dev/null
+++ b/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();
+    }
+
+}
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java
index 490be7c..0390851 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceService.java
+++ b/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
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
index 0844616..4d0fd42 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
+++ b/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)));
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java
index bd0d5dc..1713c29 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java
+++ b/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 鍚堝悓缂栧彿
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
index 0652c25..f655977 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
+++ b/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());
+        // 缁熻鍙e緞锛氳崏绋� + 瀹℃壒涓� + 瀹℃壒閫氳繃锛堜笌寮�绁ㄧ骇鏍¢獙銆佺粺璁℃柟娉� 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. 璁$畻鍚堝悓绱鍥炴閲戦锛堜笉鍚湰娆★紝缁熻鍙e緞鍚屽悎鍚岄噾棰濇牎楠岋細瀹℃壒閫氳繃 + 瀹℃壒涓級
-        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); // 涓嶅厑璁镐慨鏀圭殑瀛楁锛坕nvoiceId 鍏佽淇敼锛�
         // 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);
     }
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpFinancePaymentController.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpFinancePaymentController.java
index 718419a..21fe41e 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpFinancePaymentController.java
+++ b/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)));
         });
     }
 
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentRespVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentRespVO.java
index 14ab4c1..4bc732e 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentRespVO.java
+++ b/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 = "寮犱笁")
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentSaveReqVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentSaveReqVO.java
index 2e3bac0..8a981b4 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/vo/payment/ErpFinancePaymentSaveReqVO.java
+++ b/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;
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ErpPurchaseInvoiceController.java
new file mode 100644
index 0000000..8270f23
--- /dev/null
+++ b/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());
+        }
+        // 璁$畻姣忓紶鏉ョエ宸蹭粯娆鹃噾棰濓紙鏈鏍� + 瀹℃壒涓� + 宸插鏍革紝鍙e緞涓庡垱寤轰粯娆炬牎楠屼竴鑷达級
+        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);
+    }
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoicePageReqVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoicePageReqVO.java
new file mode 100644
index 0000000..e92270c
--- /dev/null
+++ b/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;
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceRespVO.java
new file mode 100644
index 0000000..3d84083
--- /dev/null
+++ b/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;
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/vo/invoice/ErpPurchaseInvoiceSaveReqVO.java
new file mode 100644
index 0000000..77a2916
--- /dev/null
+++ b/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;
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpFinancePaymentDO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpFinancePaymentDO.java
index dea7913..8453b75 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpFinancePaymentDO.java
+++ b/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()}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceDO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/purchase/ErpPurchaseInvoiceDO.java
new file mode 100644
index 0000000..f86231c
--- /dev/null
+++ b/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銆丳ostgreSQL銆並ingbase銆丏B2銆丠2 鏁版嵁搴撶殑涓婚敭鑷銆傚鏋滄槸 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;
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java
index 0c884d6..b08d529 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentMapper.java
+++ b/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"));
+    }
+
 }
\ No newline at end of file
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInvoiceMapper.java
new file mode 100644
index 0000000..2c9b681
--- /dev/null
+++ b/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());
+    }
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java
index fa36e91..bdce48c 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java
+++ b/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";
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseInvoiceAuditStatusEnum.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpPurchaseInvoiceAuditStatusEnum.java
new file mode 100644
index 0000000..3363972
--- /dev/null
+++ b/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;
+    }
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
index 5666784..b295513 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
+++ b/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, "閲囪喘鐢宠瀹℃壒鍒嗙被涓嶅瓨鍦紝璇峰厛鍦˙PM妯″潡鍒涘缓閲囪喘鐢宠瀹℃壒娴佺▼鍒嗙被");
     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, "鏀舵鍗曚笉瀛樺湪");
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/common/ErpBizTypeEnum.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/common/ErpBizTypeEnum.java
index 5b09756..c064166 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/common/ErpBizTypeEnum.java
+++ b/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, "閿�鍞嚭搴�"),
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentService.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentService.java
index 87c8ab8..b7c2547 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentService.java
+++ b/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);
+
     // ==================== 浠樻鍗曢」 ====================
 
     /**
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentServiceImpl.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentServiceImpl.java
index 0281b30..6be9fa0 100644
--- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpFinancePaymentServiceImpl.java
+++ b/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
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceService.java
new file mode 100644
index 0000000..8f8b01b
--- /dev/null
+++ b/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);
+
+    /**
+     * 鏇存柊鏉ョエ瀹℃壒缁撴灉锛圔PM 鍥炶皟锛�
+     *
+     * @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();
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInvoiceServiceImpl.java
new file mode 100644
index 0000000..b9a9a1c
--- /dev/null
+++ b/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();
+    }
+
+}
diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/listener/ErpPurchaseInvoiceStatusListener.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/listener/ErpPurchaseInvoiceStatusListener.java
new file mode 100644
index 0000000..e93b790
--- /dev/null
+++ b/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瀹℃壒浜嬩欢锛宨nvoiceId={}, status={}", invoiceId, event.getStatus());
+        invoiceService.updatePurchaseInvoiceAuditStatus(invoiceId, event.getStatus());
+    }
+
+}
diff --git a/yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java b/yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
index ff891ea..dd2eb29 100644
--- a/yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
+++ b/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, "瑙i櫎/缁堟鍚堝悓蹇呴』濉啓鏃ユ湡鍜屽師鍥�");
+    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, "璇ュ憳宸ュ湪鎵�閫夋椂闂存宸插瓨鍦ㄦ湭瑙i櫎/鏈粓姝㈢殑鍚堝悓锛岃鍏堣В闄�/缁堟鍚庡啀绛捐");
 
     // ========== 璇峰亣鐢宠 1-030-001-000 ==========
     ErrorCode LEAVE_APPLICATION_NOT_EXISTS = new ErrorCode(1_030_001_000, "璇峰亣鐢宠涓嶅瓨鍦�");
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeContractController.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/HrmEmployeeContractController.java
new file mode 100644
index 0000000..d956af0
--- /dev/null
+++ b/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 = "瑙i櫎/缁堟鍛樺伐鍚堝悓")
+    @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());
+    }
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractPageReqVO.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractPageReqVO.java
new file mode 100644
index 0000000..8fba285
--- /dev/null
+++ b/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 = "瑙i櫎/缁堟鐘舵�侊細0-姝e父 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;
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractRespVO.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractRespVO.java
new file mode 100644
index 0000000..8b000b5
--- /dev/null
+++ b/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 = "缁涓婁竴浠藉悎鍚孖D", 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 = "鍗楅�歑X绉戞妧鏈夐檺鍏徃")
+    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 = "瑙i櫎/缁堟鐘舵�侊細0-姝e父 1-宸茶В闄� 2-宸茬粓姝�", example = "0")
+    private Integer terminateStatus;
+
+    @Schema(description = "瑙i櫎/缁堟鏃ユ湡")
+    private LocalDate terminateDate;
+
+    @Schema(description = "瑙i櫎/缁堟鍘熷洜", example = "鍚堝悓鍒版湡涓嶇画绛�")
+    private String terminateReason;
+
+    @Schema(description = "澶囨敞", example = "绗竴娆$画绛�")
+    private String remark;
+
+    @Schema(description = "闄勪欢鍒楄〃")
+    private List<StorageBlobRespDTO> attachmentList;
+
+    @Schema(description = "鍒涘缓鏃堕棿")
+    private LocalDateTime createTime;
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractSaveReqVO.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractSaveReqVO.java
new file mode 100644
index 0000000..f39675d
--- /dev/null
+++ b/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 = "缁涓婁竴浠藉悎鍚孖D锛堢画绛炬椂鎸囧畾锛涗竴鑸笉浼狅紝鍚庣鑷姩鍏宠仈璇ュ憳宸ユ渶杩戜竴浠藉悎鍚岋級", 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 = "鍗楅�歑X绉戞妧鏈夐檺鍏徃")
+    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 = "瑙i櫎/缁堟鐘舵�侊細0-姝e父 1-宸茶В闄� 2-宸茬粓姝�", example = "0")
+    private Integer terminateStatus;
+
+    @Schema(description = "瑙i櫎/缁堟鏃ユ湡")
+    private LocalDate terminateDate;
+
+    @Schema(description = "瑙i櫎/缁堟鍘熷洜", example = "鍚堝悓鍒版湡涓嶇画绛�")
+    private String terminateReason;
+
+    @Schema(description = "澶囨敞", example = "绗竴娆$画绛�")
+    private String remark;
+
+    @Schema(description = "闄勪欢 blobId 鍒楄〃", example = "[1, 2]")
+    private List<Long> blobIds;
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractTerminateReqVO.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/employee/vo/HrmEmployeeContractTerminateReqVO.java
new file mode 100644
index 0000000..f7113ac
--- /dev/null
+++ b/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 = "绠$悊鍚庡彴 - 鍛樺伐鍚堝悓瑙i櫎/缁堟 Request VO")
+@Data
+public class HrmEmployeeContractTerminateReqVO {
+
+    @Schema(description = "鍚堝悓ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "鍚堝悓ID涓嶈兘涓虹┖")
+    private Long id;
+
+    @Schema(description = "瑙i櫎/缁堟鐘舵�侊細1-宸茶В闄� 2-宸茬粓姝�", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "瑙i櫎/缁堟鐘舵�佷笉鑳戒负绌�")
+    private Integer terminateStatus;
+
+    @Schema(description = "瑙i櫎/缁堟鏃ユ湡", requiredMode = Schema.RequiredMode.REQUIRED)
+    @NotNull(message = "瑙i櫎/缁堟鏃ユ湡涓嶈兘涓虹┖")
+    private LocalDate terminateDate;
+
+    @Schema(description = "瑙i櫎/缁堟鍘熷洜", requiredMode = Schema.RequiredMode.REQUIRED, example = "鍗忓晢涓�鑷磋В闄�")
+    private String terminateReason;
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/employee/HrmEmployeeContractDO.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/employee/HrmEmployeeContractDO.java
new file mode 100644
index 0000000..a82e305
--- /dev/null
+++ b/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;
+
+    /**
+     * 缁涓婁竴浠藉悎鍚孖D
+     *
+     * 涓�涓憳宸ュ彲绛捐澶氭鍚堝悓锛岄�氳繃璇ュ瓧娈靛舰鎴愮画绛鹃摼锛屼究浜庤拷婧悎鍚屽巻鍙�
+     */
+    private Long parentId;
+
+    /**
+     * 鏄惁褰撳墠鐢熸晥鍚堝悓
+     *
+     * 鍛樺伐鏈�鏂扮璁㈢殑銆佹湭瑙i櫎/鏈粓姝㈢殑鍚堝悓鏍囪涓哄綋鍓嶅悎鍚岋紝鍚屼竴鍛樺伐浠呬竴浠�
+     */
+    private Boolean isCurrent;
+
+    /**
+     * 鍚堝悓缂栧彿
+     *
+     * 鑷姩鐢熸垚锛欻T + 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;
+
+    /**
+     * 瑙i櫎/缁堟鐘舵��
+     *
+     * 0-姝e父 1-宸茶В闄� 2-宸茬粓姝�
+     */
+    private Integer terminateStatus;
+
+    /**
+     * 瑙i櫎/缁堟鏃ユ湡
+     */
+    private LocalDate terminateDate;
+
+    /**
+     * 瑙i櫎/缁堟鍘熷洜
+     */
+    private String terminateReason;
+
+    /**
+     * 澶囨敞
+     */
+    private String remark;
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeContractMapper.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeContractMapper.java
new file mode 100644
index 0000000..6d7e74f
--- /dev/null
+++ b/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;
+
+    /**
+     * 瑙i櫎缁堟鐘舵�侊細姝e父
+     */
+    int TERMINATE_NORMAL = 0;
+    /**
+     * 瑙i櫎缁堟鐘舵�侊細宸茶В闄�
+     */
+    int TERMINATE_CANCELED = 1;
+    /**
+     * 瑙i櫎缁堟鐘舵�侊細宸茬粓姝�
+     */
+    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)));
+    }
+
+    /**
+     * 鏌ヨ鍚屼竴鍛樺伐鏃堕棿閲嶅彔鐨勬湭瑙i櫎/鏈粓姝㈠悎鍚�
+     *
+     * @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));
+        }
+        // 鏂板悎鍚屾棤鍥哄畾鏈熼檺锛坋ndDate 涓� 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));
+    }
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/HrmNoRedisDAO.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/HrmNoRedisDAO.java
index dbcbdd9..44009fd 100644
--- a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/redis/HrmNoRedisDAO.java
+++ b/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浣嶈嚜澧�
+     * 渚嬪锛欻T202401010001
+     *
+     * @return 鍛樺伐鍚堝悓缂栧彿
+     */
+    public String generateEmployeeContractNo() {
+        return generate(EMPLOYEE_CONTRACT_NO_PREFIX, 4);
+    }
+
+    /**
      * 鐢熸垚钖叕鏍哥畻缂栧彿锛屾牸寮忥細SC + yyyyMMdd + 4浣嶈嚜澧�
      * 渚嬪锛歋C202401010001
      *
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractService.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractService.java
new file mode 100644
index 0000000..2d6167c
--- /dev/null
+++ b/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);
+
+    /**
+     * 瑙i櫎/缁堟鍛樺伐鍚堝悓
+     *
+     * @param reqVO 瑙i櫎/缁堟璇锋眰
+     */
+    void terminateContract(HrmEmployeeContractTerminateReqVO reqVO);
+
+    /**
+     * 鑾峰緱鍛樺伐鍚堝悓璇︽儏
+     *
+     * @param id 鍚堝悓ID
+     * @return 鍚堝悓璇︽儏锛堝惈闄勪欢銆佸憳宸ヤ俊鎭�佸姩鎬佺姸鎬侊級
+     */
+    HrmEmployeeContractRespVO getContract(Long id);
+
+    /**
+     * 鑾峰緱鍛樺伐鍚堝悓鍒嗛〉
+     *
+     * @param pageReqVO 鍒嗛〉璇锋眰
+     * @return 鍚堝悓鍒嗛〉缁撴灉锛堝惈鍛樺伐淇℃伅銆佸姩鎬佺姸鎬侊級
+     */
+    PageResult<HrmEmployeeContractRespVO> getContractPage(HrmEmployeeContractPageReqVO pageReqVO);
+
+    /**
+     * 缁熻鍗冲皢鍒版湡鐨勫悎鍚屾暟閲忥紙渚涘緟鍔�/棣栭〉鎻愰啋锛�
+     *
+     * @return 鍗冲皢鍒版湡鍚堝悓鏁伴噺
+     */
+    Long getExpiringCount();
+
+}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractServiceImpl.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeContractServiceImpl.java
new file mode 100644
index 0000000..7243ed7
--- /dev/null
+++ b/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锛堢敤鎴风偣鍑荤殑閭d唤鍚堝悓锛夛紝鍚﹀垯鑷姩鍏宠仈鍛樺伐鏈�杩戜竴浠藉悎鍚�
+        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. 鏍¢獙瑙i櫎/缁堟鍙傛暟
+        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. 鏇存柊瑙i櫎/缁堟淇℃伅
+        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;
+    }
+
+    /**
+     * 鏍¢獙鍚屼竴鍛樺伐鏃堕棿閲嶅彔鐨勫悎鍚岋紝閬垮厤涓�涓憳宸ュ悓鏃跺瓨鍦ㄥ浠芥湭瑙i櫎/鏈粓姝㈢殑鐢熸晥鍚堝悓
+     */
+    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());
+                }
+            }
+        }
+    }
+
+}
diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
index 67c57d7..599e513 100644
--- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
+++ b/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 閿�鍞鍗�"),

--
Gitblit v1.9.3