2026-07-02 6e312c64c056acf479a6dd8393dd42a80b69b7d4
1.新增销售报价
2.crm整合mdm
已添加14个文件
已修改10个文件
3187 ■■■■■ 文件已修改
.claude/settings.local.json 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_erp_integration_frontend.md 986 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_sale_quotation.sql 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_sales_flow.md 444 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/CrmFollowUpRecordController.java 36 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java 238 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationPageReqVO.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationRespVO.java 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationSaveReqVO.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractProductDO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationDO.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationProductDO.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationMapper.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationProductMapper.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/no/CrmNoRedisDAO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/common/CrmBizTypeEnum.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationStatusEnum.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationService.java 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java 446 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/listener/CrmSaleQuotationStatusListener.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/settings.local.json
@@ -23,9 +23,7 @@
      "Bash(dir /s /b \"C:\\\\Program Files \\(x86\\)\\\\MySQL\\\\*\\\\bin\\\\mysql.exe\")",
      "Bash(cmd.exe /c \"where mysql\")",
      "Bash(cmd.exe /c \"dir /s /b C:\\\\ProgramData\\\\MySQL\\\\*\\\\mysql.exe\")",
      "Bash(cmd.exe /c \"dir /s /b C:\\\\Program Files\\\\MySQL\\\\*\\\\mysql.exe\")",
      "Bash(mysql -u root -p123456 ruoyi-vue-pro -e \"SELECT id, name, code, deleted FROM bpm_category WHERE code = 'purchase_approve' OR name LIKE '%采购审核%';\")",
      "Bash(dir /s /b \"D:\\\\牛马\\\\南通\\\\后端\\\\mom-pro2-after\\\\yudao-module-mdm-api\\\\*.java\")"
      "Bash(cmd.exe /c \"dir /s /b C:\\\\Program Files\\\\MySQL\\\\*\\\\mysql.exe\")"
    ],
    "deny": [
      "Bash(rm -rf *)",
docs/crm_erp_integration_frontend.md
@@ -1,46 +1,52 @@
# CRM ä¸Ž ERP é”€å”®ä¸€ä½“化改造 - å‰ç«¯æ–‡æ¡£
# CRM ä¸Ž ERP é”€å”®ä¸€ä½“化改造 - å‰ç«¯æ–‡æ¡£ï¼ˆå®Œæ•´ç‰ˆï¼‰
## æ¦‚è¿°
本次改造将 CRM å®¢æˆ·ä½œä¸ºç»Ÿä¸€å®¢æˆ·ä¸»æ•°æ®ï¼ŒERP é”€å”®æ¨¡å—(销售订单、销售出库、销售退货、收款单)统一使用 CRM å®¢æˆ·ã€‚
本次改造实现 CRM ä¸Ž ERP é”€å”®æ¨¡å—的深度集成,包括:
- **Phase 1**:客户数据统一 - ERP ä½¿ç”¨ CRM å®¢æˆ·
- **Phase 2**:产品数据统一 - CRM/ERP ä½¿ç”¨ MDM ç‰©æ–™
- **Phase 3**:合同生成销售订单
- **Phase 4**:销售全流程追溯
- **Phase 5**:营销数据报表
- **Phase 6**:数据迁移与清理(SQL éƒ¨åˆ†ï¼‰
---
## ä¸€ã€æ”¹é€ æ¸…单
## ä¸€ã€æ”¹é€ æ¸…单汇总
### åŽç«¯æ”¹é€ ï¼ˆå·²å®Œæˆï¼‰
### 1.1 æ–°å¢žæ¨¡å—
| æ¨¡å— | è¯´æ˜Ž |
|------|------|
| `yudao-module-crm-api` | CRM API æ¨¡å—(客户 API、报表 API、追溯 API) |
| `yudao-module-erp-api` | ERP API æ¨¡å—(销售订单 API) |
| `yudao-module-mdm-api` | MDM API æ¨¡å—(物料 API) |
### 1.2 åŽç«¯æ”¹é€ æ–‡ä»¶
| æ–‡ä»¶ | æ”¹åŠ¨è¯´æ˜Ž |
|------|----------|
| `yudao-module-crm-api/` | æ–°å¢ž CRM å®¢æˆ· API æ¨¡å— |
| `CrmCustomerApi.java` | æ–°å¢žå®¢æˆ· API æŽ¥å£ |
| `CrmCustomerRespDTO.java` | æ–°å¢žå®¢æˆ· DTO |
| `CrmCustomerApiImpl.java` | å®žçް CRM å®¢æˆ· API |
| `CrmCustomerApi.java` | CRM å®¢æˆ· API æŽ¥å£ |
| `CrmCustomerRespDTO.java` | å®¢æˆ·å“åº” DTO |
| `CrmReportApi.java` | è¥é”€æŠ¥è¡¨ API æŽ¥å£ |
| `CrmSaleTraceApi.java` | é”€å”®è¿½æº¯ API æŽ¥å£ |
| `ErpSaleOrderApi.java` | é”€å”®è®¢å• API æŽ¥å£ |
| `MdmItemApi.java` | MDM ç‰©æ–™ API æŽ¥å£ |
| `ErpSaleOrderServiceImpl.java` | ä½¿ç”¨ CRM å®¢æˆ· API |
| `ErpFinanceReceiptServiceImpl.java` | ä½¿ç”¨ CRM å®¢æˆ· API |
| `ErpSaleOrderController.java` | ä½¿ç”¨ CRM å®¢æˆ· API |
| `ErpSaleOrderDO.java` | æ›´æ–°æ³¨é‡Šå…³è” CRM å®¢æˆ· |
| `ErpSaleOutDO.java` | æ›´æ–°æ³¨é‡Šå…³è” CRM å®¢æˆ· |
| `ErpSaleReturnDO.java` | æ›´æ–°æ³¨é‡Šå…³è” CRM å®¢æˆ· |
| `ErpFinanceReceiptDO.java` | æ›´æ–°æ³¨é‡Šå…³è” CRM å®¢æˆ· |
### åŽç«¯æ”¹é€ ï¼ˆå¾…完成)
| æ–‡ä»¶ | æ”¹åŠ¨è¯´æ˜Ž |
|------|----------|
| `ErpSaleOutController.java` | ä½¿ç”¨ CRM å®¢æˆ· API |
| `ErpSaleReturnController.java` | ä½¿ç”¨ CRM å®¢æˆ· API |
| `ErpFinanceReceiptController.java` | ä½¿ç”¨ CRM å®¢æˆ· API |
| `CrmContractServiceImpl.java` | æ–°å¢žç”Ÿæˆé”€å”®è®¢å•方法 |
| `CrmProductServiceImpl.java` | ä½¿ç”¨ MDM ç‰©æ–™ API |
| `ErpProductServiceImpl.java` | ä½¿ç”¨ MDM ç‰©æ–™ API |
---
## äºŒã€æ¶‰åŠé¡µé¢
### ERP é”€å”®æ¨¡å—
### 2.1 ERP é”€å”®æ¨¡å—
| é¡µé¢ | è·¯å¾„ | æ”¹åŠ¨è¯´æ˜Ž |
|------|------|----------|
| é”€å”®è®¢å•列表 | `/erp/sale/order/index` | å®¢æˆ·é€‰æ‹©æ”¹ä¸º CRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ |
| é”€å”®è®¢å•列表 | `/erp/sale/order/index` | å®¢æˆ·é€‰æ‹©æ”¹ä¸º CRM å®¢æˆ·ï¼Œæ–°å¢žå…³è”合同列 |
| é”€å”®è®¢å•表单 | `/erp/sale/order/form` | å®¢æˆ·é€‰æ‹©æ”¹ä¸º CRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ |
| é”€å”®å‡ºåº“列表 | `/erp/sale/out/index` | å®¢æˆ·é€‰æ‹©æ”¹ä¸º CRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ |
| é”€å”®å‡ºåº“表单 | `/erp/sale/out/form` | å®¢æˆ·é€‰æ‹©æ”¹ä¸º CRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ |
@@ -49,35 +55,43 @@
| æ”¶æ¬¾å•列表 | `/erp/finance/receipt/index` | å®¢æˆ·é€‰æ‹©æ”¹ä¸º CRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ |
| æ”¶æ¬¾å•表单 | `/erp/finance/receipt/form` | å®¢æˆ·é€‰æ‹©æ”¹ä¸º CRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ |
### 2.2 CRM åˆåŒæ¨¡å—
| é¡µé¢ | è·¯å¾„ | æ”¹åŠ¨è¯´æ˜Ž |
|------|------|----------|
| åˆåŒåˆ—表 | `/crm/contract/index` | æ·»åŠ "生成销售订单"按钮 |
| åˆåŒè¯¦æƒ… | `/crm/contract/detail` | æ˜¾ç¤ºé”€å”®è®¢å•信息、销售流程追溯 |
### 2.3 CRM å•†æœºæ¨¡å—
| é¡µé¢ | è·¯å¾„ | æ”¹åŠ¨è¯´æ˜Ž |
|------|------|----------|
| å•†æœºè¯¦æƒ… | `/crm/business/detail` | æ·»åŠ é”€å”®æµç¨‹è¿½æº¯å…¥å£ |
### 2.4 CRM å®¢æˆ·æ¨¡å—
| é¡µé¢ | è·¯å¾„ | æ”¹åŠ¨è¯´æ˜Ž |
|------|------|----------|
| å®¢æˆ·è¯¦æƒ… | `/crm/customer/detail` | æ·»åŠ é”€å”®æµç¨‹è¿½æº¯åˆ—è¡¨ |
### 2.5 CRM æŠ¥è¡¨æ¨¡å—(新增)
| é¡µé¢ | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| æŠ¥è¡¨æ¦‚览 | `/crm/report/dashboard` | æ•°æ®æ¦‚览页面 |
---
## ä¸‰ã€å®¢æˆ·é€‰æ‹©ç»„ä»¶
## ä¸‰ã€API æŽ¥å£
### 3.1 ä½¿ç”¨ CRM å®¢æˆ·é€‰æ‹©ç»„ä»¶
ERP é”€å”®æ¨¡å—的客户选择统一使用 CRM å®¢æˆ·é€‰æ‹©ç»„件。
```html
<!-- åŽŸæ¥ï¼šERP å®¢æˆ·é€‰æ‹© -->
<el-select v-model="form.customerId" placeholder="请选择客户">
  <el-option
    v-for="item in customerList"
    :key="item.id"
    :label="item.name"
    :value="item.id"
  />
</el-select>
<!-- æ”¹ä¸ºï¼šCRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ -->
<CrmCustomerSelect v-model="form.customerId" />
```
### 3.2 CRM å®¢æˆ· API
### 3.1 CRM å®¢æˆ· API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /crm/customer/simple-list | èŽ·å–å®¢æˆ·ç²¾ç®€åˆ—è¡¨ï¼ˆç”¨äºŽä¸‹æ‹‰é€‰æ‹©ï¼‰ |
| GET | /crm/customer/list-by-ids | æ‰¹é‡èŽ·å–å®¢æˆ·ä¿¡æ¯ |
| GET | /rpc-api/crm/customer/get | èŽ·å–å•ä¸ªå®¢æˆ· |
| POST | /rpc-api/crm/customer/validate | æ ¡éªŒå®¢æˆ·å­˜åœ¨ |
**客户精简列表响应:**
@@ -96,26 +110,670 @@
}
```
---
### 3.2 CRM åˆåŒ API(新增)
## å››ã€åºŸå¼ƒçš„ ERP å®¢æˆ·ç®¡ç†
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /crm/contract/generate-sale-order | æ ¹æ®åˆåŒç”Ÿæˆé”€å”®è®¢å• |
### 4.1 ERP å®¢æˆ·ç®¡ç†é¡µé¢åºŸå¼ƒ
**请求参数:**
以下页面标记为废弃,建议在菜单中隐藏或删除:
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | åˆåŒç¼–号 |
- `/erp/customer/index` - ERP å®¢æˆ·åˆ—表
- `/erp/customer/form` - ERP å®¢æˆ·è¡¨å•
**响应:**
```json
{
  "code": 0,
  "data": 12345,
  "msg": "操作成功"
}
```
### 3.3 CRM é”€å”®è¿½æº¯ API(新增)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /rpc-api/crm/sale-trace/get-by-business-id | æ ¹æ®å•†æœºç¼–号获取追溯信息 |
| GET | /rpc-api/crm/sale-trace/get-by-contract-id | æ ¹æ®åˆåŒç¼–号获取追溯信息 |
| GET | /rpc-api/crm/sale-trace/get-list-by-customer-id | æ ¹æ®å®¢æˆ·ç¼–号获取追溯列表 |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "businessId": 1,
    "businessName": "商机A",
    "businessPrice": 100000,
    "customerId": 1,
    "customerName": "客户A",
    "contractId": 1,
    "contractName": "合同A",
    "contractPrice": 95000,
    "contractStatus": 20,
    "orderId": 1,
    "orderNo": "SO001",
    "orderPrice": 95000,
    "receivablePrice": 50000,
    "receivableItems": [
      { "receivableId": 1, "receivableNo": "RK001", "price": 30000 }
    ]
  }
}
```
### 3.4 CRM è¥é”€æŠ¥è¡¨ API(新增)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /rpc-api/crm/report/get-customer-analyze | èŽ·å–å®¢æˆ·åˆ†æžç»Ÿè®¡ |
| GET | /rpc-api/crm/report/get-business-analyze | èŽ·å–å•†æœºåˆ†æžç»Ÿè®¡ |
| GET | /rpc-api/crm/report/get-contract-analyze | èŽ·å–åˆåŒåˆ†æžç»Ÿè®¡ |
| GET | /rpc-api/crm/report/get-sales-funnel | èŽ·å–é”€å”®æ¼æ–—ç»Ÿè®¡ |
| GET | /rpc-api/crm/report/get-sales-performance | èŽ·å–é”€å”®ä¸šç»©ç»Ÿè®¡ |
**客户分析响应:**
```json
{
  "code": 0,
  "data": {
    "totalCount": 1000,
    "todayCount": 15,
    "dealCount": 300,
    "dealRate": 30.00,
    "lockCount": 50,
    "poolCount": 200
  }
}
```
**商机分析响应:**
```json
{
  "code": 0,
  "data": {
    "totalCount": 500,
    "todayCount": 8,
    "totalPrice": 5000000.00,
    "successCount": 150,
    "successPrice": 2000000.00,
    "successRate": 30.00,
    "processingCount": 250,
    "failCount": 100
  }
}
```
**合同分析响应:**
```json
{
  "code": 0,
  "data": {
    "totalCount": 400,
    "todayCount": 5,
    "totalPrice": 3000000.00,
    "approveCount": 300,
    "approvePrice": 2500000.00,
    "processCount": 20,
    "receivablePrice": 1500000.00,
    "receivableRate": 60.00
  }
}
```
**销售漏斗响应:**
```json
{
  "code": 0,
  "data": {
    "stages": [
      { "name": "客户", "count": 1000, "price": 0, "conversionRate": 100.00 },
      { "name": "商机", "count": 500, "price": 5000000.00, "conversionRate": 50.00 },
      { "name": "合同", "count": 300, "price": 2500000.00, "conversionRate": 30.00 },
      { "name": "回款", "count": null, "price": 1500000.00, "conversionRate": 15.00 }
    ]
  }
}
```
**销售业绩响应:**
```json
{
  "code": 0,
  "data": {
    "items": [
      {
        "userId": 1,
        "userName": "张三",
        "deptName": "销售一部",
        "contractCount": 50,
        "contractPrice": 500000.00,
        "receivablePrice": 300000.00,
        "businessCount": 80,
        "businessPrice": 800000.00
      }
    ]
  }
}
```
### 3.5 ERP é”€å”®è®¢å• API(新增 RPC)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /rpc-api/erp/sale-order/create | åˆ›å»ºé”€å”®è®¢å• |
| GET | /rpc-api/erp/sale-order/get | èŽ·å–é”€å”®è®¢å• |
| GET | /rpc-api/erp/sale-order/get-by-contract-id | æ ¹æ®åˆåŒç¼–号获取订单 |
### 3.6 MDM ç‰©æ–™ API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /rpc-api/mdm/item/create | åˆ›å»ºç‰©æ–™ |
| PUT | /rpc-api/mdm/item/update | æ›´æ–°ç‰©æ–™ |
| GET | /rpc-api/mdm/item/get | èŽ·å–ç‰©æ–™ |
| POST | /rpc-api/mdm/item/list | èŽ·å–ç‰©æ–™åˆ—è¡¨ |
| GET | /rpc-api/mdm/item/validate-exists | æ ¡éªŒç‰©æ–™å­˜åœ¨ |
---
## äº”、SQL æ‰§è¡Œ
## å››ã€å‰ç«¯ä¿®æ”¹ç‚¹
见 `docs/crm_erp_integration_sql.sql`
### 4.1 å®¢æˆ·é€‰æ‹©ç»„ä»¶
ERP é”€å”®æ¨¡å—的客户选择统一使用 CRM å®¢æˆ·é€‰æ‹©ç»„件。
```html
<!-- åŽŸæ¥ï¼šERP å®¢æˆ·é€‰æ‹© -->
<el-select v-model="form.customerId" placeholder="请选择客户">
  <el-option
    v-for="item in customerList"
    :key="item.id"
    :label="item.name"
    :value="item.id"
  />
</el-select>
<!-- æ”¹ä¸ºï¼šCRM å®¢æˆ·é€‰æ‹©ç»„ä»¶ -->
<CrmCustomerSelect v-model="form.customerId" />
```
**组件实现:**
```html
<!-- CrmCustomerSelect.vue -->
<template>
  <el-select
    v-model="selectedValue"
    placeholder="请选择客户"
    filterable
    remote
    :remote-method="searchCustomer"
    @focus="loadCustomers"
  >
    <el-option
      v-for="item in customerList"
      :key="item.id"
      :label="item.name"
      :value="item.id"
    >
      <span>{{ item.name }}</span>
      <span style="color: #999; margin-left: 8px;">{{ item.mobile }}</span>
    </el-option>
  </el-select>
</template>
<script>
import { getCustomerSimpleList } from '@/api/crm/customer'
export default {
  props: {
    value: [Number, String]
  },
  data() {
    return {
      customerList: [],
      selectedValue: this.value
    }
  },
  watch: {
    value(val) {
      this.selectedValue = val
    },
    selectedValue(val) {
      this.$emit('input', val)
    }
  },
  methods: {
    async loadCustomers() {
      if (this.customerList.length === 0) {
        const { data } = await getCustomerSimpleList()
        this.customerList = data
      }
    },
    async searchCustomer(query) {
      if (query) {
        // å¯é€‰ï¼šå®žçŽ°è¿œç¨‹æœç´¢
      }
    }
  }
}
</script>
```
### 4.2 åˆåŒåˆ—表页面
**文件路径:** `/src/views/crm/contract/index.vue`
**新增按钮:**
```html
<!-- åœ¨åˆåŒæ“ä½œåˆ—添加 -->
<el-table-column label="操作" width="200">
  <template #default="{ row }">
    <el-button
      v-if="row.auditStatus === 20 && !row.orderId"
      type="success"
      size="small"
      @click="handleGenerateOrder(row)"
    >
      ç”Ÿæˆé”€å”®è®¢å•
    </el-button>
    <el-button
      v-if="row.orderId"
      type="primary"
      size="small"
      @click="handleViewOrder(row)"
    >
      æŸ¥çœ‹è®¢å•
    </el-button>
  </template>
</el-table-column>
```
**新增方法:**
```js
import { generateSaleOrder } from '@/api/crm/contract'
methods: {
  async handleGenerateOrder(row) {
    try {
      await this.$confirm('确认根据该合同生成销售订单?', '提示')
      const { data } = await generateSaleOrder(row.id)
      this.$message.success('生成销售订单成功')
      this.getList()
    } catch (e) {
      if (e !== 'cancel') {
        this.$message.error(e.message || '生成失败')
      }
    }
  },
  handleViewOrder(row) {
    this.$router.push({
      path: '/erp/sale/order/detail',
      query: { id: row.orderId }
    })
  }
}
```
### 4.3 é”€å”®è®¢å•列表页面
**文件路径:** `/src/views/erp/sale/order/index.vue`
**新增列:**
```html
<el-table-column label="关联合同" prop="contractNo" width="150">
  <template #default="{ row }">
    <el-link
      v-if="row.contractId"
      type="primary"
      @click="handleViewContract(row)"
    >
      {{ row.contractNo }}
    </el-link>
    <span v-else>-</span>
  </template>
</el-table-column>
```
### 4.4 å•†æœºè¯¦æƒ…页面 - é”€å”®è¿½æº¯
**文件路径:** `/src/views/crm/business/detail.vue`
```html
<el-card header="销售流程追溯">
  <el-timeline v-if="traceInfo">
    <el-timeline-item
      :timestamp="traceInfo.businessCreateTime"
      color="#409EFF"
    >
      <el-card>
        <h4>商机创建</h4>
        <p>商机名称:{{ traceInfo.businessName }}</p>
        <p>商机金额:¥{{ traceInfo.businessPrice }}</p>
      </el-card>
    </el-timeline-item>
    <el-timeline-item
      v-if="traceInfo.contractId"
      :timestamp="traceInfo.contractCreateTime"
      color="#67C23A"
    >
      <el-card>
        <h4>合同签订</h4>
        <p>合同名称:{{ traceInfo.contractName }}</p>
        <p>合同金额:¥{{ traceInfo.contractPrice }}</p>
      </el-card>
    </el-timeline-item>
    <el-timeline-item
      v-if="traceInfo.orderId"
      :timestamp="traceInfo.orderCreateTime"
      color="#E6A23C"
    >
      <el-card>
        <h4>销售订单</h4>
        <p>订单号:
          <el-link type="primary" @click="viewOrder(traceInfo.orderId)">
            {{ traceInfo.orderNo }}
          </el-link>
        </p>
        <p>订单金额:¥{{ traceInfo.orderPrice }}</p>
      </el-card>
    </el-timeline-item>
    <el-timeline-item v-if="traceInfo.receivablePrice > 0" color="#F56C6C">
      <el-card>
        <h4>回款记录</h4>
        <p>已回款金额:¥{{ traceInfo.receivablePrice }}</p>
      </el-card>
    </el-timeline-item>
  </el-timeline>
  <el-empty v-else description="暂无销售流程数据" />
</el-card>
```
### 4.5 æŠ¥è¡¨æ¦‚览页面
**文件路径:** `/src/views/crm/report/dashboard.vue`
```html
<template>
  <div class="report-dashboard">
    <!-- ç»Ÿè®¡å¡ç‰‡ -->
    <el-row :gutter="20">
      <el-col :span="6">
        <el-card class="stat-card">
          <div class="stat-title">客户总数</div>
          <div class="stat-value">{{ customerAnalyze.totalCount }}</div>
          <div class="stat-footer">今日新增:{{ customerAnalyze.todayCount }}</div>
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card class="stat-card">
          <div class="stat-title">商机总数</div>
          <div class="stat-value">{{ businessAnalyze.totalCount }}</div>
          <div class="stat-footer">转化率:{{ businessAnalyze.successRate }}%</div>
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card class="stat-card">
          <div class="stat-title">合同金额</div>
          <div class="stat-value">Â¥{{ contractAnalyze.totalPrice }}</div>
          <div class="stat-footer">已回款:¥{{ contractAnalyze.receivablePrice }}</div>
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card class="stat-card">
          <div class="stat-title">回款率</div>
          <div class="stat-value">{{ contractAnalyze.receivableRate }}%</div>
        </el-card>
      </el-col>
    </el-row>
    <!-- é”€å”®ä¸šç»©æŽ’行 -->
    <el-card header="销售业绩排行">
      <el-table :data="performanceData.items" stripe>
        <el-table-column prop="userName" label="销售人员" />
        <el-table-column prop="deptName" label="部门" />
        <el-table-column prop="contractCount" label="合同数量" width="100" />
        <el-table-column prop="contractPrice" label="合同金额" width="150" />
        <el-table-column prop="receivablePrice" label="回款金额" width="150" />
      </el-table>
    </el-card>
  </div>
</template>
<script>
import {
  getCustomerAnalyze,
  getBusinessAnalyze,
  getContractAnalyze,
  getSalesPerformance
} from '@/api/crm/report'
export default {
  data() {
    return {
      customerAnalyze: {},
      businessAnalyze: {},
      contractAnalyze: {},
      performanceData: { items: [] }
    }
  },
  created() {
    this.loadData()
  },
  methods: {
    async loadData() {
      const [c, b, ct, p] = await Promise.all([
        getCustomerAnalyze(),
        getBusinessAnalyze(),
        getContractAnalyze(),
        getSalesPerformance()
      ])
      this.customerAnalyze = c.data
      this.businessAnalyze = b.data
      this.contractAnalyze = ct.data
      this.performanceData = p.data
    }
  }
}
</script>
```
### 4.6 API å®šä¹‰æ–‡ä»¶
**文件路径:** `/src/api/crm/contract.js`
```js
// ç”Ÿæˆé”€å”®è®¢å•
export function generateSaleOrder(id) {
  return request({
    url: '/crm/contract/generate-sale-order',
    method: 'post',
    params: { id }
  })
}
```
**文件路径:** `/src/api/crm/saleTrace.js`
```js
// æ ¹æ®å•†æœºç¼–号获取销售流程追溯信息
export function getTraceByBusinessId(businessId) {
  return request({
    url: '/crm/sale-trace/get-by-business-id',
    method: 'get',
    params: { businessId }
  })
}
// æ ¹æ®åˆåŒç¼–号获取销售流程追溯信息
export function getTraceByContractId(contractId) {
  return request({
    url: '/crm/sale-trace/get-by-contract-id',
    method: 'get',
    params: { contractId }
  })
}
// æ ¹æ®å®¢æˆ·ç¼–号获取销售流程追溯列表
export function getTraceListByCustomerId(customerId) {
  return request({
    url: '/crm/sale-trace/get-list-by-customer-id',
    method: 'get',
    params: { customerId }
  })
}
```
**文件路径:** `/src/api/crm/report.js`
```js
// èŽ·å–å®¢æˆ·åˆ†æžç»Ÿè®¡
export function getCustomerAnalyze(startTime, endTime) {
  return request({
    url: '/crm/report/get-customer-analyze',
    method: 'get',
    params: { startTime, endTime }
  })
}
// èŽ·å–å•†æœºåˆ†æžç»Ÿè®¡
export function getBusinessAnalyze(startTime, endTime) {
  return request({
    url: '/crm/report/get-business-analyze',
    method: 'get',
    params: { startTime, endTime }
  })
}
// èŽ·å–åˆåŒåˆ†æžç»Ÿè®¡
export function getContractAnalyze(startTime, endTime) {
  return request({
    url: '/crm/report/get-contract-analyze',
    method: 'get',
    params: { startTime, endTime }
  })
}
// èŽ·å–é”€å”®æ¼æ–—ç»Ÿè®¡
export function getSalesFunnel() {
  return request({
    url: '/crm/report/get-sales-funnel',
    method: 'get'
  })
}
// èŽ·å–é”€å”®ä¸šç»©ç»Ÿè®¡
export function getSalesPerformance(startTime, endTime) {
  return request({
    url: '/crm/report/get-sales-performance',
    method: 'get',
    params: { startTime, endTime }
  })
}
```
---
## å…­ã€æ”¹é€ å®ŒæˆçŠ¶æ€
## äº”、数据模型变更
### 5.1 CRM åˆåŒè¡¨
```sql
ALTER TABLE `crm_contract`
ADD COLUMN `order_id` bigint DEFAULT NULL COMMENT '关联 ERP é”€å”®è®¢å•编号' AFTER `total_price`,
ADD COLUMN `order_no` varchar(32) DEFAULT NULL COMMENT 'ERP é”€å”®è®¢å•单号' AFTER `order_id`;
```
### 5.2 CRM å•†æœºè¡¨
```sql
ALTER TABLE `crm_business`
ADD COLUMN `order_id` bigint DEFAULT NULL COMMENT '关联 ERP é”€å”®è®¢å•编号' AFTER `total_price`,
ADD COLUMN `order_no` varchar(32) DEFAULT NULL COMMENT 'ERP é”€å”®è®¢å•单号' AFTER `order_id`;
```
### 5.3 ERP é”€å”®è®¢å•表
```sql
ALTER TABLE `erp_sale_order`
ADD COLUMN `contract_id` bigint DEFAULT NULL COMMENT '关联 CRM åˆåŒç¼–号' AFTER `customer_id`,
ADD COLUMN `contract_no` varchar(32) DEFAULT NULL COMMENT 'CRM åˆåŒå•号' AFTER `contract_id`;
```
---
## å…­ã€ä¸šåŠ¡è§„åˆ™
### 6.1 ç”Ÿæˆé”€å”®è®¢å•条件
- åˆåŒå¿…须审批通过(auditStatus = 20)
- åˆåŒæœªå…³è”销售订单(orderId ä¸ºç©ºï¼‰
- åˆåŒå¿…须包含产品信息
### 6.2 å®¢æˆ·æ•°æ®è§„则
- ERP é”€å”®æ¨¡å—统一使用 CRM å®¢æˆ·
- åŽŸ ERP å®¢æˆ·è¡¨ï¼ˆerp_customer)废弃
- å®¢æˆ·é€‰æ‹©é€šè¿‡ CRM å®¢æˆ· API èŽ·å–
### 6.3 äº§å“æ•°æ®è§„则
- CRM å’Œ ERP äº§å“ç»Ÿä¸€ä½¿ç”¨ MDM ç‰©æ–™ï¼ˆitemType=2)
- MDM ç‰©æ–™ç±»åž‹ï¼š1-物料,2-产品,3-半成品
---
## ä¸ƒã€ä¸šåŠ¡æµç¨‹å›¾
```
客户管理(CRM) â†’ å•†æœºè·Ÿè¿›(CRM) â†’ åˆåŒç­¾è®¢(CRM) â†’ åˆåŒå®¡æ‰¹
                                                      â†“
                                              ç”Ÿæˆé”€å”®è®¢å•(ERP)
                                                      â†“
                                              è®¢å•审批(ERP)
                                                      â†“
                                              é”€å”®å‡ºåº“(ERP)
                                                      â†“
                                              å®¢æˆ·å›žæ¬¾(ERP)
```
**销售漏斗转化:**
```
客户(1000) â†’ å•†æœº(500) â†’ åˆåŒ(300) â†’ å›žæ¬¾(150)
    â†“            â†“            â†“           â†“
  100%        50%          30%         15%
```
---
## å…«ã€æ³¨æ„äº‹é¡¹
1. **数据权限**:报表数据需要根据用户权限过滤
2. **状态控制**:只有审批通过的合同才能生成订单
3. **唯一性**:一个合同只能生成一个销售订单
4. **数据同步**:生成订单后,合同与订单双向关联
5. **废弃页面**:ERP å®¢æˆ·ç®¡ç†é¡µé¢ï¼ˆ/erp/customer/index)建议在菜单中隐藏
---
## ä¹ã€å®ŒæˆçŠ¶æ€
| Phase | å†…容 | çŠ¶æ€ |
|-------|------|------|
@@ -128,13 +786,223 @@
---
## ä¸ƒã€äº§å‡ºæ–‡æ¡£æ¸…单
## åä¸€ã€é”€å”®æŠ¥ä»·æ¨¡å—(新增)
### 11.1 æ¶‰åŠé¡µé¢
| é¡µé¢ | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| æŠ¥ä»·å•列表 | `/crm/sale-quotation/index` | æŠ¥ä»·å•列表、操作按钮 |
| æŠ¥ä»·å•表单 | `/crm/sale-quotation/form` | æ–°å»º/编辑报价单 |
| æŠ¥ä»·å•详情 | `/crm/sale-quotation/detail` | æŸ¥çœ‹è¯¦æƒ…、审批进度 |
### 11.2 API æŽ¥å£
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /crm/sale-quotation/create | åˆ›å»ºæŠ¥ä»·å• |
| PUT | /crm/sale-quotation/update | æ›´æ–°æŠ¥ä»·å• |
| DELETE | /crm/sale-quotation/delete | åˆ é™¤æŠ¥ä»·å• |
| GET | /crm/sale-quotation/get | èŽ·å–æŠ¥ä»·å•è¯¦æƒ… |
| GET | /crm/sale-quotation/page | åˆ†é¡µæŸ¥è¯¢ |
| GET | /crm/sale-quotation/page-by-customer | æŒ‰å®¢æˆ·åˆ†é¡µ |
| GET | /crm/sale-quotation/page-by-business | æŒ‰å•†æœºåˆ†é¡µ |
| PUT | /crm/sale-quotation/submit | æäº¤å®¡æ‰¹ï¼ˆéœ€ä¼  processDefinitionKey) |
| GET | /crm/sale-quotation/approve-process-list | èŽ·å–å¯é€‰å®¡æ‰¹æµç¨‹åˆ—è¡¨ |
| POST | /crm/sale-quotation/convert-contract | è½¬ä¸ºåˆåŒ |
**请求参数示例**(创建报价单):
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| name | String | æ˜¯ | æŠ¥ä»·å•名称 |
| customerId | Long | æ˜¯ | å®¢æˆ·ç¼–号 |
| businessId | Long | å¦ | å•†æœºç¼–号 |
| contactId | Long | å¦ | è”系人编号 |
| quotationTime | LocalDateTime | å¦ | æŠ¥ä»·æ—¥æœŸ |
| validUntil | LocalDateTime | å¦ | æœ‰æ•ˆæœŸè‡³ |
| discountPercent | BigDecimal | å¦ | æ•´å•折扣 |
| taxRate | BigDecimal | å¦ | ç¨Žçއ |
| fileUrl | String | å¦ | é™„件地址 |
| remark | String | å¦ | å¤‡æ³¨ |
| items | List | æ˜¯ | ç‰©æ–™æ˜Žç»†åˆ—表 |
**物料明细 Item**:
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| itemId | Long | æ˜¯ | MDM ç‰©æ–™ç¼–号 |
| quotationPrice | BigDecimal | æ˜¯ | æŠ¥ä»·å•ä»· |
| count | BigDecimal | æ˜¯ | æ•°é‡ |
| taxPercent | BigDecimal | å¦ | ç¨Žçއ |
| remark | String | å¦ | å¤‡æ³¨ |
### 11.3 å‰ç«¯ä¿®æ”¹ç‚¹
#### æŠ¥ä»·å•列表页面
**文件路径:** `/src/views/crm/sale-quotation/index.vue`
```html
<!-- æ“ä½œåˆ— -->
<el-table-column label="操作" width="280">
  <template #default="{ row }">
    <el-button v-if="row.status === 0" type="primary" size="small" @click="handleUpdate(row)">编辑</el-button>
    <el-button v-if="row.status === 0" type="success" size="small" @click="handleSubmit(row)">提交审批</el-button>
    <el-button v-if="row.status === 20 && !row.contractId" type="warning" size="small" @click="handleConvertContract(row)">转合同</el-button>
    <el-button v-if="row.contractId" type="primary" size="small" @click="handleViewContract(row)">查看合同</el-button>
    <el-button v-if="row.status === 0" type="danger" size="small" @click="handleDelete(row)">删除</el-button>
  </template>
</el-table-column>
```
**提交审批弹窗**:
```html
<el-dialog title="选择审批流程" v-model="submitDialogVisible" width="400px">
  <el-form label-width="100px">
    <el-form-item label="审批流程" required>
      <el-select v-model="selectedProcessKey" placeholder="请选择" style="width: 100%">
        <el-option v-for="item in processList" :key="item.key" :label="item.name" :value="item.key" />
      </el-select>
    </el-form-item>
  </el-form>
  <template #footer>
    <el-button @click="submitDialogVisible = false">取消</el-button>
    <el-button type="primary" @click="confirmSubmit" :loading="submitLoading">确定</el-button>
  </template>
</el-dialog>
```
**JS æ–¹æ³•**:
```js
import { getSaleQuotationApproveProcessList, submitSaleQuotation, convertSaleQuotationToContract } from '@/api/crm/saleQuotation'
methods: {
  async handleSubmit(row) {
    const { data } = await getSaleQuotationApproveProcessList()
    this.processList = data
    this.currentRow = row
    this.submitDialogVisible = true
  },
  async confirmSubmit() {
    if (!this.selectedProcessKey) {
      this.$message.warning('请选择审批流程')
      return
    }
    this.submitLoading = true
    try {
      await submitSaleQuotation(this.currentRow.id, this.selectedProcessKey)
      this.$message.success('提交成功')
      this.submitDialogVisible = false
      this.getList()
    } finally {
      this.submitLoading = false
    }
  },
  async handleConvertContract(row) {
    try {
      await this.$confirm('确认将该报价单转为合同?', '提示')
      const { data } = await convertSaleQuotationToContract(row.id)
      this.$message.success('转合同成功')
      this.$router.push({ path: '/crm/contract/detail', query: { id: data } })
    } catch (e) {
      if (e !== 'cancel') this.$message.error(e.message || '转合同失败')
    }
  }
}
```
#### æŠ¥ä»·å•表单页面 - MDM ç‰©æ–™é€‰æ‹©
**文件路径:** `/src/views/crm/sale-quotation/form.vue`
由于报价单使用 MDM ç‰©æ–™ï¼Œéœ€è¦ä½¿ç”¨ MDM ç‰©æ–™é€‰æ‹©ç»„件:
```html
<MdmItemSelect v-model="item.itemId" @change="handleItemChange" />
```
**API å®šä¹‰æ–‡ä»¶**:`/src/api/crm/saleQuotation.js`
```js
import request from '@/utils/request'
// åˆ›å»ºæŠ¥ä»·å•
export function createSaleQuotation(data) {
  return request({ url: '/crm/sale-quotation/create', method: 'post', data })
}
// æ›´æ–°æŠ¥ä»·å•
export function updateSaleQuotation(data) {
  return request({ url: '/crm/sale-quotation/update', method: 'put', data })
}
// åˆ é™¤æŠ¥ä»·å•
export function deleteSaleQuotation(ids) {
  return request({ url: '/crm/sale-quotation/delete', method: 'delete', params: { ids } })
}
// èŽ·å–æŠ¥ä»·å•è¯¦æƒ…
export function getSaleQuotation(id) {
  return request({ url: '/crm/sale-quotation/get', method: 'get', params: { id } })
}
// åˆ†é¡µæŸ¥è¯¢
export function getSaleQuotationPage(params) {
  return request({ url: '/crm/sale-quotation/page', method: 'get', params })
}
// æäº¤å®¡æ‰¹
export function submitSaleQuotation(id, processDefinitionKey) {
  return request({ url: '/crm/sale-quotation/submit', method: 'put', params: { id, processDefinitionKey } })
}
// èŽ·å–å®¡æ‰¹æµç¨‹åˆ—è¡¨
export function getSaleQuotationApproveProcessList() {
  return request({ url: '/crm/sale-quotation/approve-process-list', method: 'get' })
}
// è½¬åˆåŒ
export function convertSaleQuotationToContract(id) {
  return request({ url: '/crm/sale-quotation/convert-contract', method: 'post', params: { id } })
}
```
### 11.4 ä¸šåŠ¡æµç¨‹
```
商机(可选) â†’ é”€å”®æŠ¥ä»·(草稿) â†’ æäº¤å®¡æ‰¹ â†’ å®¡æ‰¹ä¸­ â†’ å®¡æ‰¹é€šè¿‡ â†’ è½¬åˆåŒ
                                    â†“
                              å®¡æ‰¹æ‹’绝 â†’ é‡æ–°æäº¤æˆ–修改
```
### 11.5 æ³¨æ„äº‹é¡¹
1. æŠ¥ä»·å•和合同统一使用 **MDM ç‰©æ–™**(`MdmItemApi`),与 ERP é”€å”®è®¢å•一致
2. BPM åˆ†ç±»ç¼–码 `sale_quotation_approve` éœ€è¦åœ¨ BPM ç®¡ç†åŽå°åˆ›å»º
3. æŠ¥ä»·å•转合同时,物料明细直接复制到合同产品表(字段一致)
4. æŠ¥ä»·å•号前缀为 `QT`,格式:QT + yyyyMMdd + 6位自增序号
5. å•†æœºå…³è”为可选,报价单可以独立创建不依赖商机
### 11.6 åˆåŒäº§å“å­—段变更说明
合同产品表 `crm_contract_product` å·²æ”¹ä¸ºä½¿ç”¨ MDM ç‰©æ–™ï¼š
| åŽŸå­—æ®µ | æ–°å­—段 | è¯´æ˜Ž |
|--------|--------|------|
| `product_id` | `item_id` | MDM ç‰©æ–™ç¼–号 |
| `product_price` | `item_price` | ç‰©æ–™åŽŸä»· |
| - | `item_unit_id` | è®¡é‡å•位编号 |
前端表单需要使用 MDM ç‰©æ–™é€‰æ‹©ç»„件替代 CRM äº§å“é€‰æ‹©ç»„件。
---
## åã€äº§å‡ºæ–‡æ¡£æ¸…单
| æ–‡æ¡£ | è¯´æ˜Ž |
|------|------|
| `docs/crm_erp_integration_frontend.md` | CRM å®¢æˆ·ä¸€ä½“化前端文档 |
| `docs/crm_erp_integration_frontend.md` | æœ¬æ–‡æ¡£ |
| `docs/crm_erp_integration_sql.sql` | SQL å˜æ›´è„šæœ¬ |
| `docs/mdm_product_integration_frontend.md` | MDM äº§å“ä¸€ä½“化前端文档 |
| `docs/contract_generate_order_frontend.md` | åˆåŒç”Ÿæˆè®¢å•前端文档 |
| `docs/sales_traceability_frontend.md` | é”€å”®è¿½æº¯å‰ç«¯æ–‡æ¡£ |
| `docs/marketing_report_frontend.md` | è¥é”€æŠ¥è¡¨å‰ç«¯æ–‡æ¡£ |
docs/crm_sale_quotation.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
-- =============================================
-- é”€å”®æŠ¥ä»·æ¨¡å—数据库表
-- =============================================
-- 1. æŠ¥ä»·å•主表
CREATE TABLE IF NOT EXISTS `crm_sale_quotation` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
  `no` varchar(32) NOT NULL COMMENT '报价单号',
  `name` varchar(128) NOT NULL COMMENT '报价单名称',
  `customer_id` bigint NOT NULL COMMENT '客户编号',
  `business_id` bigint DEFAULT NULL COMMENT '商机编号',
  `contact_id` bigint DEFAULT NULL COMMENT '联系人编号',
  `owner_user_id` bigint NOT NULL COMMENT '负责人编号',
  `status` int NOT NULL DEFAULT 0 COMMENT '状态(0草稿 10审批中 20已通过 30已拒绝 40已取消 50已转合同)',
  `process_instance_id` varchar(64) DEFAULT NULL COMMENT '工作流编号',
  `quotation_time` datetime DEFAULT NULL COMMENT '报价日期',
  `valid_until` datetime DEFAULT NULL COMMENT '有效期至',
  `total_product_price` decimal(24,6) DEFAULT NULL COMMENT '产品总金额',
  `discount_percent` decimal(10,2) DEFAULT 0 COMMENT '整单折扣(%)',
  `discount_price` decimal(24,6) DEFAULT 0 COMMENT '优惠金额',
  `total_price` decimal(24,6) DEFAULT NULL COMMENT '报价总金额',
  `tax_rate` decimal(10,2) DEFAULT NULL COMMENT '税率(%)',
  `tax_price` decimal(24,6) DEFAULT NULL COMMENT '税额',
  `contract_id` bigint DEFAULT NULL COMMENT '关联合同编号',
  `contract_no` varchar(32) DEFAULT NULL COMMENT '关联合同单号',
  `file_url` varchar(512) DEFAULT NULL COMMENT '附件地址',
  `remark` varchar(1024) DEFAULT NULL COMMENT '备注',
  `creator` varchar(64) DEFAULT NULL COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) DEFAULT NULL COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_no` (`no`),
  KEY `idx_customer_id` (`customer_id`),
  KEY `idx_business_id` (`business_id`),
  KEY `idx_owner_user_id` (`owner_user_id`)
) ENGINE=InnoDB COMMENT='CRM é”€å”®æŠ¥ä»·å•';
-- 2. æŠ¥ä»·å•产品明细表
CREATE TABLE IF NOT EXISTS `crm_sale_quotation_product` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
  `quotation_id` bigint NOT NULL COMMENT '报价单编号',
  `item_id` bigint NOT NULL COMMENT 'MDM物料编号',
  `item_unit_id` bigint DEFAULT NULL COMMENT '计量单位编号',
  `item_price` decimal(24,6) NOT NULL COMMENT '物料原价(冗余)',
  `quotation_price` decimal(24,6) NOT NULL COMMENT '报价单价',
  `count` decimal(24,6) NOT NULL COMMENT '数量',
  `total_price` decimal(24,6) NOT NULL COMMENT '小计金额',
  `tax_percent` decimal(10,2) DEFAULT NULL COMMENT '税率(%)',
  `tax_price` decimal(24,6) DEFAULT NULL COMMENT '税额',
  `remark` varchar(256) DEFAULT NULL COMMENT '备注',
  `creator` varchar(64) DEFAULT NULL COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) DEFAULT NULL COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
  PRIMARY KEY (`id`),
  KEY `idx_quotation_id` (`quotation_id`)
) ENGINE=InnoDB COMMENT='CRM é”€å”®æŠ¥ä»·å•产品明细';
-- =============================================
-- åˆåŒäº§å“è¡¨ä¿®æ”¹ï¼ˆä½¿ç”¨ MDM ç‰©æ–™ï¼‰
-- =============================================
-- 3. ä¿®æ”¹åˆåŒäº§å“è¡¨ç»“æž„
-- æ³¨æ„ï¼šå¦‚果已有数据,需要先处理数据迁移
-- ALTER TABLE `crm_contract_product` DROP COLUMN `product_id`;
-- ALTER TABLE `crm_contract_product` DROP COLUMN `product_price`;
ALTER TABLE `crm_contract_product`
  ADD COLUMN `item_id` bigint DEFAULT NULL COMMENT 'MDM物料编号' AFTER `contract_id`,
  ADD COLUMN `item_unit_id` bigint DEFAULT NULL COMMENT '计量单位编号' AFTER `item_id`,
  ADD COLUMN `item_price` decimal(24,6) DEFAULT NULL COMMENT '物料原价(冗余)' AFTER `item_unit_id`;
-- æ•°æ®è¿ç§»ï¼šå°† product_id é‡å‘½åä¸º item_id(如果需要保留原有数据)
-- UPDATE `crm_contract_product` SET `item_id` = `product_id`, `item_price` = `product_price`;
-- =============================================
-- BPM åˆ†ç±»ï¼ˆéœ€è¦åœ¨ BPM ç®¡ç†åŽå°åˆ›å»ºï¼Œè¿™é‡Œæä¾› SQL å‚考)
-- =============================================
-- INSERT INTO `bpm_category` (`name`, `code`, `description`, `status`, `sort`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
-- VALUES ('销售报价审批', 'sale_quotation_approve', '销售报价单审批流程分类', 0, 0, '1', NOW(), '1', NOW(), b'0');
docs/crm_sales_flow.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,444 @@
# CRM ä¸Ž ERP é”€å”®æµç¨‹å…¨æ™¯å›¾
## ä¸€ã€æ•´ä½“业务流程
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           CRM é”€å”®çº¿ç´¢ç®¡ç†ï¼ˆå‰ç«¯ï¼‰                                    â”‚
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     â”‚
│   çº¿ç´¢(clue) â”€â”€è½¬åŒ–──► å®¢æˆ·(customer) â”€â”€è·Ÿè¿›â”€â”€â–º å•†æœº(business)                       â”‚
│       â”‚                 â”‚                    â”‚                                      â”‚
│       â”‚                 â”‚                    â”‚                                      â”‚
│       â–¼                 â–¼                    â–¼                                      â”‚
│   çº¿ç´¢å…¥åº“          å®¢æˆ·å…¬æµ·æ±             å•†æœºçŠ¶æ€æŽ¨è¿›                               â”‚
│   çº¿ç´¢åˆ†é…          å®¢æˆ·é”å®š              èµ¢å•/输单                                 â”‚
│                                                                                     â”‚
└─────────────────────────────────────────────────────────────────────────────────────┘
                                        â”‚
                                        â–¼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           CRM é”€å”®æŠ¥ä»·ä¸ŽåˆåŒï¼ˆæ–°å¢žï¼‰                                  â”‚
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     â”‚
│   é”€å”®æŠ¥ä»·(quotation) â”€â”€BPM审批──► åˆåŒ(contract)                                   â”‚
│       â”‚                      â”‚              â”‚                                       â”‚
│       â”‚                      â”‚              â”‚                                       â”‚
│       â–¼                      â–¼              â–¼                                       â”‚
│   åˆ›å»ºæŠ¥ä»·å•            å®¡æ‰¹é€šè¿‡          åˆåŒç­¾è®¢                                   â”‚
│   é€‰æ‹©MDM物料           å®¡æ‰¹æ‹’绝          åˆåŒå®¡æ‰¹                                   â”‚
│   æäº¤å®¡æ‰¹              å®¡æ‰¹å–消          ç”Ÿæˆè®¢å•                                   â”‚
│   è½¬ä¸ºåˆåŒ                               å…³è”商机                                   â”‚
│                                                                                     â”‚
└─────────────────────────────────────────────────────────────────────────────────────┘
                                        â”‚
                                        â–¼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           ERP é”€å”®è®¢å•与执行                                         â”‚
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     â”‚
│   é”€å”®è®¢å•(order) â”€â”€â–º é”€å”®å‡ºåº“(out) â”€â”€â–º é”€å”®é€€è´§(return)                            â”‚
│       â”‚                   â”‚                  â”‚                                      â”‚
│       â”‚                   â”‚                  â”‚                                      â”‚
│       â–¼                   â–¼                  â–¼                                      â”‚
│   è®¢å•审批             å‡ºåº“审核            é€€è´§å®¡æ ¸                                 â”‚
│   å…³è”合同             å‡ºåº“单号            é€€è´§å•号                                 â”‚
│   é€‰æ‹©MDM物料          å‡ºåº“数量            é€€è´§æ•°é‡                                 â”‚
│                                                                                     â”‚
└─────────────────────────────────────────────────────────────────────────────────────┘
                                        â”‚
                                        â–¼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           ERP è´¢åŠ¡æ”¶æ¬¾                                               â”‚
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                     â”‚
│   æ”¶æ¬¾å•(receipt) â”€â”€â–º æ”¶æ¬¾æ ¸é”€                                                       â”‚
│       â”‚                                                                              â”‚
│       â–¼                                                                              â”‚
│   å…³è”销售订单                                                                        â”‚
│   å…³è”CRM客户                                                                         â”‚
│   æ”¶æ¬¾é‡‘额                                                                            â”‚
│                                                                                     â”‚
└─────────────────────────────────────────────────────────────────────────────────────┘
```
---
## äºŒã€æ•°æ®æµè½¬è¯¦è§£
### 2.1 å®¢æˆ·æ•°æ®ï¼ˆç»Ÿä¸€æ¥æºï¼‰
| æ¨¡å— | æ•°æ®æ¥æº | è¯´æ˜Ž |
|------|----------|------|
| CRM å®¢æˆ· | `crm_customer` | **唯一客户数据源** |
| ERP é”€å”®è®¢å• | `crm_customer` (通过 API) | ä½¿ç”¨ CRM å®¢æˆ· |
| ERP é”€å”®å‡ºåº“ | `crm_customer` (通过 API) | ä½¿ç”¨ CRM å®¢æˆ· |
| ERP é”€å”®é€€è´§ | `crm_customer` (通过 API) | ä½¿ç”¨ CRM å®¢æˆ· |
| ERP æ”¶æ¬¾å• | `crm_customer` (通过 API) | ä½¿ç”¨ CRM å®¢æˆ· |
**关键 API**:`CrmCustomerApi`
- `/rpc-api/crm/customer/get` - èŽ·å–å•ä¸ªå®¢æˆ·
- `/rpc-api/crm/customer/list-by-ids` - æ‰¹é‡èŽ·å–å®¢æˆ·
- `/rpc-api/crm/customer/validate` - æ ¡éªŒå®¢æˆ·å­˜åœ¨
### 2.2 ç‰©æ–™æ•°æ®ï¼ˆç»Ÿä¸€æ¥æºï¼‰
| æ¨¡å— | æ•°æ®æ¥æº | è¯´æ˜Ž |
|------|----------|------|
| MDM ç‰©æ–™ | `mdm_item` | **唯一物料数据源** |
| é”€å”®æŠ¥ä»· | `mdm_item` (通过 API) | ä½¿ç”¨ MDM ç‰©æ–™ |
| CRM åˆåŒ | `mdm_item` (通过 API) | ä½¿ç”¨ MDM ç‰©æ–™ |
| ERP é”€å”®è®¢å• | `mdm_item` (通过 API) | ä½¿ç”¨ MDM ç‰©æ–™ |
**关键 API**:`MdmItemApi`
- `/rpc-api/mdm/item/get` - èŽ·å–ç‰©æ–™
- `/rpc-api/mdm/item/list` - æ‰¹é‡èŽ·å–ç‰©æ–™
- `/rpc-api/mdm/item/validate-item-exists` - æ ¡éªŒç‰©æ–™å­˜åœ¨
---
## ä¸‰ã€è¯¦ç»†æµç¨‹å›¾
### 3.1 CRM é”€å”®æŠ¥ä»·æµç¨‹ï¼ˆæ–°å¢žï¼‰
```
┌──────────────┐     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
│   åˆ›å»ºæŠ¥ä»·å•  â”‚────►│   æäº¤å®¡æ‰¹    â”‚────►│   BPM审批    â”‚────►│   å®¡æ‰¹é€šè¿‡    â”‚
│              â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
│ é€‰æ‹©å®¢æˆ·     â”‚     â”‚ é€‰æ‹©æµç¨‹     â”‚     â”‚ å®¡æ‰¹ä¸­çŠ¶æ€   â”‚     â”‚ å·²é€šè¿‡çŠ¶æ€   â”‚
│ é€‰æ‹©MDM物料  â”‚     â”‚ åŠ¨æ€é€‰æ‹©     â”‚     â”‚ status=10   â”‚     â”‚ status=20   â”‚
│ å¡«å†™æŠ¥ä»·é‡‘额 â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
│ å¯å…³è”商机   â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
└──────────────┘     â””──────────────┘     â””──────────────┘     â””──────────────┘
                                                                   â”‚
                                                                   â–¼
┌──────────────┐     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
│   è½¬ä¸ºåˆåŒ    â”‚────►│   åˆåŒç”Ÿæˆ    â”‚
│              â”‚     â”‚              â”‚
│ ç‰©æ–™ç›´æŽ¥å¤åˆ¶ â”‚     â”‚ åˆåŒå®¡æ‰¹æµç¨‹ â”‚
│ å­—段一致     â”‚     â”‚ ç”Ÿæˆé”€å”®è®¢å• â”‚
│ çŠ¶æ€=50     â”‚     â”‚              â”‚
└──────────────┘     â””──────────────┘
```
**状态流转**:
| çŠ¶æ€å€¼ | çŠ¶æ€å | å¯æ“ä½œ |
|--------|--------|--------|
| 0 | è‰ç¨¿ | ç¼–辑、删除、提交审批 |
| 10 | å®¡æ‰¹ä¸­ | æ—  |
| 20 | å·²é€šè¿‡ | è½¬åˆåŒ |
| 30 | å·²æ‹’绝 | é‡æ–°æäº¤ |
| 40 | å·²å–消 | æ—  |
| 50 | å·²è½¬åˆåŒ | æŸ¥çœ‹åˆåŒ |
### 3.2 CRM åˆåŒæµç¨‹
```
┌──────────────┐     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
│   åˆ›å»ºåˆåŒ    â”‚────►│   æäº¤å®¡æ‰¹    â”‚────►│   BPM审批    â”‚────►│   å®¡æ‰¹é€šè¿‡    â”‚
│              â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
│ é€‰æ‹©å®¢æˆ·     â”‚     â”‚ å›ºå®šæµç¨‹Key  â”‚     â”‚ å®¡æ‰¹ä¸­çŠ¶æ€   â”‚     â”‚ å·²é€šè¿‡çŠ¶æ€   â”‚
│ é€‰æ‹©MDM物料  â”‚     â”‚ crm-contract â”‚     â”‚ auditStatus â”‚     â”‚ auditStatus â”‚
│ åˆåŒé‡‘额     â”‚     â”‚ -audit       â”‚     â”‚   =10       â”‚     â”‚   =20       â”‚
│ å¯å…³è”商机   â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
└──────────────┘     â””──────────────┘     â””──────────────┘     â””──────────────┘
                                                                   â”‚
                                                                   â–¼
                                                            â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
                                                            â”‚ ç”Ÿæˆé”€å”®è®¢å•  â”‚
                                                            â”‚              â”‚
                                                            â”‚ ERP订单创建  â”‚
                                                            â”‚ å…³è”合同编号 â”‚
                                                            â”‚ ç‰©æ–™æ˜Žç»†å¤åˆ¶ â”‚
                                                            â””──────────────┘
```
### 3.3 ERP é”€å”®è®¢å•流程
```
┌──────────────┐     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
│   åˆ›å»ºè®¢å•    â”‚────►│   è®¢å•审批    â”‚────►│   å®¡æ‰¹é€šè¿‡    â”‚────►│   é”€å”®å‡ºåº“    â”‚
│              â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
│ CRM客户      â”‚     â”‚ ERP审批流程  â”‚     â”‚ å¯æ‰§è¡Œå‡ºåº“   â”‚     â”‚ å‡ºåº“单生成   â”‚
│ MDM物料      â”‚     â”‚              â”‚     â”‚              â”‚     â”‚ å‡ºåº“数量累计 â”‚
│ å…³è”合同(可) â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
│ è®¢å•金额     â”‚     â”‚              â”‚     â”‚              â”‚     â”‚              â”‚
└──────────────┘     â””──────────────┘     â””──────────────┘     â””──────────────┘
                                                                   â”‚
                                                                   â–¼
                                                            â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
                                                            â”‚   æ”¶æ¬¾æ ¸é”€    â”‚
                                                            â”‚              â”‚
                                                            â”‚ æ”¶æ¬¾å•创建   â”‚
                                                            â”‚ å…³è”订单     â”‚
                                                            â”‚ æ”¶æ¬¾é‡‘额累计 â”‚
                                                            â””──────────────┘
```
---
## å››ã€æ¨¡å—关系图
```
                    â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
                    â”‚           MDM ä¸»æ•°æ®æ¨¡å—             â”‚
                    â”‚                                     â”‚
                    â”‚  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”‚
                    â”‚  â”‚ mdm_item    â”‚  â”‚ mdm_unit    â”‚  â”‚
                    â”‚  â”‚ (物料)      â”‚  â”‚ (单位)      â”‚  â”‚
                    â”‚  â””─────────────┘  â””─────────────┘  â”‚
                    â”‚                                     â”‚
                    â””──────────────┬──────────────────────┘
                                   â”‚
                                   â”‚ MdmItemApi
                                   â”‚
          â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¼â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
          â”‚                        â”‚                        â”‚
          â–¼                        â–¼                        â–¼
┌─────────────────┐    â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”    â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
│   CRM æ¨¡å—       â”‚    â”‚   ERP æ¨¡å—       â”‚    â”‚   BPM æ¨¡å—      â”‚
│                 â”‚    â”‚                 â”‚    â”‚                 â”‚
│ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚    â”‚ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚    â”‚ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚
│ â”‚crm_customer â”‚ â”‚    â”‚ â”‚erp_sale_    â”‚ â”‚    â”‚ â”‚ bpm_category│ â”‚
│ â”‚(客户)       â”‚◄├────┤ â”‚  order      â”‚ â”‚    â”‚ â”‚ (审批分类)  â”‚ â”‚
│ â””─────────────┘ â”‚    â”‚ â”‚(销售订单)   â”‚ â”‚    â”‚ â””─────────────┘ â”‚
│                 â”‚    â”‚ â””─────────────┘ â”‚    â”‚                 â”‚
│ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚    â”‚ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚    â”‚ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚
│ â”‚crm_sale_    â”‚ â”‚    â”‚ â”‚erp_sale_    â”‚ â”‚    â”‚ â”‚ bpm_process â”‚ â”‚
│ â”‚ quotation   â”‚ â”‚    â”‚ â”‚  out        â”‚ â”‚    â”‚ â”‚ _definition â”‚ â”‚
│ â”‚(销售报价)   â”‚ â”‚    â”‚ â”‚(销售出库)   â”‚ â”‚    â”‚ â”‚ (流程定义)  â”‚ â”‚
│ â””─────────────┘ â”‚    â”‚ â””─────────────┘ â”‚    â”‚ â””─────────────┘ â”‚
│                 â”‚    â”‚                 â”‚    â”‚                 â”‚
│ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚    â”‚ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â” â”‚    â”‚                 â”‚
│ â”‚crm_contract â”‚ â”‚    â”‚ â”‚erp_finance_ â”‚ â”‚    â”‚                 â”‚
│ â”‚(合同)       â”‚◄├────┤ â”‚  receipt    â”‚ â”‚    â”‚                 â”‚
│ â””─────────────┘ â”‚    â”‚ â”‚(收款单)     â”‚ â”‚    â”‚                 â”‚
│                 â”‚    â”‚ â””─────────────┘ â”‚    â”‚                 â”‚
│                 â”‚    â”‚                 â”‚    â”‚                 â”‚
└─────────────────┘    â””─────────────────┘    â””─────────────────┘
```
---
## äº”、关键表结构
### 5.1 CRM è¡¨
| è¡¨å | è¯´æ˜Ž | å…³é”®å­—段 |
|------|------|----------|
| `crm_customer` | å®¢æˆ·ä¸»è¡¨ | id, name, owner_user_id |
| `crm_business` | å•†æœºä¸»è¡¨ | id, customer_id, total_price, status_id |
| `crm_sale_quotation` | é”€å”®æŠ¥ä»· | id, no, customer_id, business_id, status |
| `crm_sale_quotation_product` | æŠ¥ä»·æ˜Žç»† | quotation_id, **item_id**(MDM物料) |
| `crm_contract` | åˆåŒä¸»è¡¨ | id, no, customer_id, business_id, audit_status |
| `crm_contract_product` | åˆåŒæ˜Žç»† | contract_id, **item_id**(MDM物料) |
### 5.2 ERP è¡¨
| è¡¨å | è¯´æ˜Ž | å…³é”®å­—段 |
|------|------|----------|
| `erp_sale_order` | é”€å”®è®¢å• | id, no, **customer_id**(CRM), contract_id |
| `erp_sale_order_items` | è®¢å•明细 | order_id, **item_id**(MDM物料) |
| `erp_sale_out` | é”€å”®å‡ºåº“ | id, order_id, customer_id |
| `erp_finance_receipt` | æ”¶æ¬¾å• | id, **customer_id**(CRM) |
---
## å…­ã€API è°ƒç”¨å…³ç³»
### 6.1 CRM è°ƒç”¨ MDM
```
CrmSaleQuotationServiceImpl
    â””─► MdmItemApi.validateItemExists(itemId)     // æ ¡éªŒç‰©æ–™å­˜åœ¨
    â””─► MdmItemApi.getItemMap(itemIds)            // æ‰¹é‡èŽ·å–ç‰©æ–™ä¿¡æ¯
CrmContractServiceImpl
    â””─► MdmItemApi.validateItemExists(itemId)     // æ ¡éªŒç‰©æ–™å­˜åœ¨
    â””─► MdmItemApi.getItemMap(itemIds)            // æ‰¹é‡èŽ·å–ç‰©æ–™ä¿¡æ¯
```
### 6.2 ERP è°ƒç”¨ CRM
```
ErpSaleOrderServiceImpl
    â””─► CrmCustomerApi.getCustomer(customerId)    // èŽ·å–CRM客户
    â””─► CrmCustomerApi.validateCustomer(customerId) // æ ¡éªŒå®¢æˆ·å­˜åœ¨
ErpFinanceReceiptServiceImpl
    â””─► CrmCustomerApi.getCustomer(customerId)    // èŽ·å–CRM客户
```
### 6.3 CRM è°ƒç”¨ BPM
```
CrmSaleQuotationServiceImpl
    â””─► BpmProcessInstanceApi.createProcessInstance()  // åˆ›å»ºå®¡æ‰¹æµç¨‹å®žä¾‹
    â””─► BpmCategoryService.getCategoryMap()            // èŽ·å–å®¡æ‰¹åˆ†ç±»
    â””─► BpmProcessDefinitionService.getProcessDefinitionInfoListByCategory() // èŽ·å–æµç¨‹å®šä¹‰
CrmContractServiceImpl
    â””─► BpmProcessInstanceApi.createProcessInstance()  // åˆ›å»ºå®¡æ‰¹æµç¨‹å®žä¾‹
```
---
## ä¸ƒã€çŠ¶æ€æžšä¸¾å¯¹ç…§
### 7.1 é”€å”®æŠ¥ä»·çŠ¶æ€ (CrmSaleQuotationStatusEnum)
| å€¼ | æžšä¸¾ | è¯´æ˜Ž |
|---|------|------|
| 0 | DRAFT | è‰ç¨¿ |
| 10 | PROCESS | å®¡æ‰¹ä¸­ |
| 20 | APPROVE | å·²é€šè¿‡ |
| 30 | REJECT | å·²æ‹’绝 |
| 40 | CANCEL | å·²å–消 |
| 50 | CONVERTED | å·²è½¬åˆåŒ |
### 7.2 åˆåŒå®¡æ‰¹çŠ¶æ€ (CrmAuditStatusEnum)
| å€¼ | æžšä¸¾ | è¯´æ˜Ž |
|---|------|------|
| 0 | DRAFT | æœªæäº¤ |
| 10 | PROCESS | å®¡æ‰¹ä¸­ |
| 20 | APPROVE | å®¡æ ¸é€šè¿‡ |
| 30 | REJECT | å®¡æ ¸ä¸é€šè¿‡ |
| 40 | CANCEL | å·²å–消 |
### 7.3 BPM ä»»åŠ¡çŠ¶æ€ (BpmTaskStatusEnum)
| å€¼ | æžšä¸¾ | è¯´æ˜Ž |
|---|------|------|
| 2 | APPROVE | å®¡æ‰¹é€šè¿‡ |
| 3 | REJECT | å®¡æ‰¹ä¸é€šè¿‡ |
| 4 | CANCEL | å·²å–消 |
**状态映射**:BPM状态 â†’ ä¸šåŠ¡çŠ¶æ€
- `2(APPROVE)` â†’ `20(已通过)`
- `3(REJECT)` â†’ `30(已拒绝)`
- `4(CANCEL)` â†’ `40(已取消)`
---
## å…«ã€æ“ä½œæƒé™è¯´æ˜Ž
### 8.1 é”€å”®æŠ¥ä»·æ“ä½œæƒé™
| çŠ¶æ€ | ç¼–辑 | åˆ é™¤ | æäº¤å®¡æ‰¹ | è½¬åˆåŒ |
|------|------|------|----------|--------|
| è‰ç¨¿(0) | âœ… | âœ… | âœ… | âŒ |
| å®¡æ‰¹ä¸­(10) | âŒ | âŒ | âŒ | âŒ |
| å·²é€šè¿‡(20) | âŒ | âŒ | âŒ | âœ… |
| å·²æ‹’绝(30) | âœ… | âŒ | âœ… | âŒ |
| å·²è½¬åˆåŒ(50) | âŒ | âŒ | âŒ | âŒ |
### 8.2 åˆåŒæ“ä½œæƒé™
| çŠ¶æ€ | ç¼–辑 | åˆ é™¤ | æäº¤å®¡æ‰¹ | ç”Ÿæˆè®¢å• |
|------|------|------|----------|----------|
| æœªæäº¤(0) | âœ… | âœ… | âœ… | âŒ |
| å®¡æ‰¹ä¸­(10) | âœ… | âŒ | âŒ | âŒ |
| å·²é€šè¿‡(20) | âŒ | âŒ | âŒ | âœ… |
| å·²æ‹’绝(30) | âœ… | âŒ | âœ… | âŒ |
---
## ä¹ã€å®Œæ•´ä¸šåŠ¡é“¾æ¡ç¤ºä¾‹
```
示例:一个客户从线索到回款的完整流程
1. ã€çº¿ç´¢é˜¶æ®µã€‘
   - é”€å”®äººå‘˜å½•入线索 â†’ çº¿ç´¢çŠ¶æ€ï¼šå¾…è·Ÿè¿›
   - çº¿ç´¢è½¬åŒ–为客户 â†’ å®¢æˆ·åˆ›å»ºæˆåŠŸ
2. ã€å®¢æˆ·è·Ÿè¿›ã€‘
   - åˆ›å»ºè·Ÿè¿›è®°å½• â†’ è®°å½•沟通内容
   - åˆ›å»ºå•†æœº â†’ å•†æœºé‡‘额:100,000元
   - å•†æœºçŠ¶æ€æŽ¨è¿› â†’ èµ¢å•
3. ã€æŠ¥ä»·é˜¶æ®µã€‘(新增)
   - åˆ›å»ºé”€å”®æŠ¥ä»·å• â†’ æŠ¥ä»·é‡‘额:95,000元
   - é€‰æ‹©MDM物料 â†’ ç‰©æ–™A、物料B
   - æäº¤å®¡æ‰¹ â†’ é€‰æ‹©å®¡æ‰¹æµç¨‹
   - å®¡æ‰¹é€šè¿‡ â†’ çŠ¶æ€ï¼šå·²é€šè¿‡
4. ã€åˆåŒé˜¶æ®µã€‘
   - æŠ¥ä»·å•转合同 â†’ åˆåŒè‡ªåŠ¨åˆ›å»º
   - åˆåŒå®¡æ‰¹ â†’ BPM审批流程
   - å®¡æ‰¹é€šè¿‡ â†’ çŠ¶æ€ï¼šå®¡æ ¸é€šè¿‡
5. ã€è®¢å•阶段】
   - åˆåŒç”Ÿæˆé”€å”®è®¢å• â†’ ERP订单创建
   - è®¢å•关联合同 â†’ åˆåŒç¼–号记录
   - è®¢å•审批 â†’ ERP审批流程
   - å®¡æ‰¹é€šè¿‡ â†’ å¯æ‰§è¡Œå‡ºåº“
6. ã€å‡ºåº“阶段】
   - åˆ›å»ºé”€å”®å‡ºåº“单 â†’ é€‰æ‹©ä»“库
   - å‡ºåº“审核 â†’ å‡ºåº“完成
   - å‡ºåº“数量回写订单
7. ã€æ”¶æ¬¾é˜¶æ®µã€‘
   - åˆ›å»ºæ”¶æ¬¾å• â†’ å…³è”订单
   - æ”¶æ¬¾é‡‘额:50,000元
   - æ”¶æ¬¾æ ¸é”€ â†’ è®¢å•收款累计
8. ã€è¿½æº¯æŸ¥è¯¢ã€‘
   - æŸ¥è¯¢é”€å”®æµç¨‹è¿½æº¯ â†’ æ˜¾ç¤ºå®Œæ•´é“¾æ¡
   - å•†æœº â†’ æŠ¥ä»· â†’ åˆåŒ â†’ è®¢å• â†’ å‡ºåº“ â†’ æ”¶æ¬¾
```
---
## åã€æ•°æ®è¿ç§»æ³¨æ„äº‹é¡¹
### 10.1 åˆåŒäº§å“è¡¨å˜æ›´
原表使用 CRM äº§å“ï¼ŒçŽ°æ”¹ä¸º MDM ç‰©æ–™ï¼š
```sql
-- æ–°å¢žå­—段
ALTER TABLE crm_contract_product
  ADD COLUMN item_id bigint COMMENT 'MDM物料编号',
  ADD COLUMN item_unit_id bigint COMMENT '计量单位编号',
  ADD COLUMN item_price decimal(24,6) COMMENT '物料原价';
-- æ•°æ®è¿ç§»ï¼ˆå¦‚果有映射关系)
-- UPDATE crm_contract_product SET item_id = product_id;
```
### 10.2 BPM åˆ†ç±»åˆ›å»º
需要在 BPM ç®¡ç†åŽå°åˆ›å»ºå®¡æ‰¹åˆ†ç±»ï¼š
```sql
INSERT INTO bpm_category (name, code, description, status, sort)
VALUES ('销售报价审批', 'sale_quotation_approve', '销售报价单审批流程分类', 0, 0);
```
---
## åä¸€ã€æ€»ç»“
### ç»Ÿä¸€æ•°æ®æº
| æ•°æ®ç±»åž‹ | ç»Ÿä¸€æ¥æº | ä½¿ç”¨æ¨¡å— |
|----------|----------|----------|
| å®¢æˆ· | CRM (`crm_customer`) | CRM、ERP销售全流程 |
| ç‰©æ–™ | MDM (`mdm_item`) | é”€å”®æŠ¥ä»·ã€åˆåŒã€ERP订单 |
### æ ¸å¿ƒæµç¨‹
```
客户(CRM) â†’ å•†æœº(CRM) â†’ æŠ¥ä»·(CRM+BPM) â†’ åˆåŒ(CRM+BPM) â†’ è®¢å•(ERP) â†’ å‡ºåº“(ERP) â†’ æ”¶æ¬¾(ERP)
```
### å…³é”®æ”¹è¿›
1. **数据统一**:客户和物料数据不再分散,统一管理
2. **流程衔接**:报价 â†’ åˆåŒ â†’ è®¢å•,数据无缝流转
3. **BPM集成**:报价和合同都支持审批流程
4. **追溯完整**:从商机到回款全链条可追溯
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java
@@ -142,17 +142,21 @@
        @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "888")
        private Long id;
        @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        private Long productId;
        @Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
        private String productName;
        @Schema(description = "产品条码", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        private String productNo;
        @Schema(description = "产品单位", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
        private Integer productUnit;
        @Schema(description = "MDM ç‰©æ–™ç¼–号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        private Long itemId;
        @Schema(description = "物料编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "ITEM001")
        private String itemCode;
        @Schema(description = "物料名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "物料A")
        private String itemName;
        @Schema(description = "物料条码", example = "BAR001")
        private String itemBarCode;
        @Schema(description = "计量单位编号", example = "1")
        private Long itemUnitId;
        @Schema(description = "计量单位名称", example = "个")
        private String itemUnitName;
        @Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
        private BigDecimal productPrice;
        @Schema(description = "物料原价", requiredMode = Schema.RequiredMode.REQUIRED, example = "100.00")
        private BigDecimal itemPrice;
        @Schema(description = "合同价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
        private BigDecimal contractPrice;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java
@@ -90,13 +90,13 @@
    @AllArgsConstructor
    public static class Product {
        @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        @NotNull(message = "产品编号不能为空")
        private Long productId;
        @Schema(description = "MDM ç‰©æ–™ç¼–号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        @NotNull(message = "物料编号不能为空")
        private Long itemId;
        @Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
        @NotNull(message = "产品单价不能为空")
        private BigDecimal productPrice;
        @Schema(description = "物料原价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
        @NotNull(message = "物料原价不能为空")
        private BigDecimal itemPrice;
        @Schema(description = "合同价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
        @NotNull(message = "合同价格不能为空")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/CrmFollowUpRecordController.java
@@ -28,7 +28,9 @@
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -92,22 +94,40 @@
        PageResult<CrmFollowUpRecordDO> pageResult = followUpRecordService.getFollowUpRecordPage(pageReqVO);
        // 1.1 æŸ¥è¯¢è”系人和商机
        Map<Long, CrmContactDO> contactMap = contactService.getContactMap(
                convertSetByFlatMap(pageResult.getList(), item -> item.getContactIds().stream()));
                convertSetByFlatMap(pageResult.getList(), item -> {
                    List<Long> contactIds = item.getContactIds();
                    return contactIds != null ? contactIds.stream() : Stream.empty();
                }));
        Map<Long, CrmBusinessDO> businessMap = businessService.getBusinessMap(
                convertSetByFlatMap(pageResult.getList(), item -> item.getBusinessIds().stream()));
                convertSetByFlatMap(pageResult.getList(), item -> {
                    List<Long> businessIds = item.getBusinessIds();
                    return businessIds != null ? businessIds.stream() : Stream.empty();
                }));
        // 1.2 æŸ¥è¯¢ç”¨æˆ·
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(pageResult.getList(), item -> Long.valueOf(item.getCreator())));
                convertSet(pageResult.getList(), item -> {
                    String creator = item.getCreator();
                    return creator != null && !creator.isEmpty() ? Long.valueOf(creator) : null;
                }));
        // 2. æ‹¼æŽ¥æ•°æ®
        PageResult<CrmFollowUpRecordRespVO> voPageResult = BeanUtils.toBean(pageResult, CrmFollowUpRecordRespVO.class, record -> {
            // 2.1 è®¾ç½®è”系人和商机信息
            record.setBusinesses(new ArrayList<>()).setContacts(new ArrayList<>());
            record.getContactIds().forEach(id -> MapUtils.findAndThen(contactMap, id, contact ->
                    record.getContacts().add(new CrmBusinessRespVO().setId(contact.getId()).setName(contact.getName()))));
            record.getBusinessIds().forEach(id -> MapUtils.findAndThen(businessMap, id, business ->
                    record.getBusinesses().add(new CrmBusinessRespVO().setId(business.getId()).setName(business.getName()))));
            List<Long> contactIds = record.getContactIds();
            if (contactIds != null) {
                contactIds.forEach(id -> MapUtils.findAndThen(contactMap, id, contact ->
                        record.getContacts().add(new CrmBusinessRespVO().setId(contact.getId()).setName(contact.getName()))));
            }
            List<Long> businessIds = record.getBusinessIds();
            if (businessIds != null) {
                businessIds.forEach(id -> MapUtils.findAndThen(businessMap, id, business ->
                        record.getBusinesses().add(new CrmBusinessRespVO().setId(business.getId()).setName(business.getName()))));
            }
            // 2.2 è®¾ç½®ç”¨æˆ·ä¿¡æ¯
            MapUtils.findAndThen(userMap, Long.valueOf(record.getCreator()), user -> record.setCreatorName(user.getNickname()));
            String creator = record.getCreator();
            if (creator != null && !creator.isEmpty()) {
                MapUtils.findAndThen(userMap, Long.valueOf(creator), user -> record.setCreatorName(user.getNickname()));
            }
        });
        return success(voPageResult);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,238 @@
package cn.iocoder.yudao.module.crm.controller.admin.quotation;
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.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import cn.iocoder.yudao.module.crm.service.quotation.CrmSaleQuotationService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi;
import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO;
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.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.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.util.collection.CollectionUtils.convertMultiMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - CRM é”€å”®æŠ¥ä»·å•")
@RestController
@RequestMapping("/crm/sale-quotation")
@Validated
public class CrmSaleQuotationController {
    @Resource
    private CrmSaleQuotationService saleQuotationService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private DeptApi deptApi;
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @PostMapping("/create")
    @Operation(summary = "创建销售报价单")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:create')")
    public CommonResult<Long> createSaleQuotation(@Valid @RequestBody CrmSaleQuotationSaveReqVO createReqVO) {
        return success(saleQuotationService.createSaleQuotation(createReqVO, getLoginUserId()));
    }
    @PutMapping("/update")
    @Operation(summary = "更新销售报价单")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:update')")
    public CommonResult<Boolean> updateSaleQuotation(@Valid @RequestBody CrmSaleQuotationSaveReqVO updateReqVO) {
        saleQuotationService.updateSaleQuotation(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除销售报价单")
    @Parameter(name = "ids", description = "编号数组", required = true)
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:delete')")
    public CommonResult<Boolean> deleteSaleQuotation(@RequestParam("ids") List<Long> ids) {
        saleQuotationService.deleteSaleQuotation(ids);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得销售报价单")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:query')")
    public CommonResult<CrmSaleQuotationRespVO> getSaleQuotation(@RequestParam("id") Long id) {
        CrmSaleQuotationDO saleQuotation = saleQuotationService.getSaleQuotation(id);
        if (saleQuotation == null) {
            return success(null);
        }
        List<CrmSaleQuotationProductDO> productList = saleQuotationService.getSaleQuotationProductListByQuotationId(id);
        return success(buildSaleQuotationRespVO(saleQuotation, productList));
    }
    @GetMapping("/page")
    @Operation(summary = "获得销售报价单分页")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:query')")
    public CommonResult<PageResult<CrmSaleQuotationRespVO>> getSaleQuotationPage(@Valid CrmSaleQuotationPageReqVO pageReqVO) {
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationService.getSaleQuotationPage(pageReqVO, getLoginUserId());
        return success(buildSaleQuotationRespVOPageResult(pageResult));
    }
    @GetMapping("/page-by-customer")
    @Operation(summary = "获得销售报价单分页(基于客户)")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:query')")
    public CommonResult<PageResult<CrmSaleQuotationRespVO>> getSaleQuotationPageByCustomerId(@Valid CrmSaleQuotationPageReqVO pageReqVO) {
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationService.getSaleQuotationPageByCustomerId(pageReqVO);
        return success(buildSaleQuotationRespVOPageResult(pageResult));
    }
    @GetMapping("/page-by-business")
    @Operation(summary = "获得销售报价单分页(基于商机)")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:query')")
    public CommonResult<PageResult<CrmSaleQuotationRespVO>> getSaleQuotationPageByBusinessId(@Valid CrmSaleQuotationPageReqVO pageReqVO) {
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationService.getSaleQuotationPageByBusinessId(pageReqVO);
        return success(buildSaleQuotationRespVOPageResult(pageResult));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出销售报价单 Excel")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportSaleQuotationExcel(@Valid CrmSaleQuotationPageReqVO pageReqVO,
                                         HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
        List<CrmSaleQuotationRespVO> list = buildSaleQuotationRespVOPageResult(
                saleQuotationService.getSaleQuotationPage(pageReqVO, getLoginUserId())).getList();
        ExcelUtils.write(response, "销售报价单.xls", "数据", CrmSaleQuotationRespVO.class, list);
    }
    @PutMapping("/submit")
    @Operation(summary = "提交销售报价审批")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:submit')")
    public CommonResult<Boolean> submitSaleQuotation(@RequestParam("id") Long id,
                                                      @RequestParam("processDefinitionKey") String processDefinitionKey) {
        saleQuotationService.submitSaleQuotation(id, processDefinitionKey, getLoginUserId());
        return success(true);
    }
    @GetMapping("/approve-process-list")
    @Operation(summary = "获取销售报价审批流程列表")
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:query')")
    public CommonResult<List<Map<String, Object>>> getSaleQuotationApproveProcessList() {
        return success(saleQuotationService.getSaleQuotationApproveProcessDefinitionList());
    }
    @PostMapping("/convert-contract")
    @Operation(summary = "转为合同")
    @Parameter(name = "id", description = "报价单编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:sale-quotation:convert-contract')")
    public CommonResult<Long> convertToContract(@RequestParam("id") Long id) {
        return success(saleQuotationService.convertToContract(id, getLoginUserId()));
    }
    // ==================== æž„建响应 VO ====================
    private CrmSaleQuotationRespVO buildSaleQuotationRespVO(CrmSaleQuotationDO saleQuotation, List<CrmSaleQuotationProductDO> productList) {
        CrmSaleQuotationRespVO respVO = BeanUtils.toBean(saleQuotation, CrmSaleQuotationRespVO.class);
        // 1. æž„建物料明细
        if (CollUtil.isNotEmpty(productList)) {
            Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(convertSet(productList, CrmSaleQuotationProductDO::getItemId));
            Map<Long, MdmUnitMeasureRespDTO> unitMap = mdmUnitMeasureApi.getUnitMeasureMap(
                    convertSet(productList, CrmSaleQuotationProductDO::getItemUnitId));
            respVO.setItems(BeanUtils.toBean(productList, CrmSaleQuotationRespVO.Item.class, item -> {
                MdmItemRespDTO mdmItem = itemMap.get(item.getItemId());
                if (mdmItem != null) {
                    item.setItemCode(mdmItem.getCode());
                    item.setItemName(mdmItem.getName());
                    item.setItemBarCode(mdmItem.getBarCode());
                }
                MapUtils.findAndThen(unitMap, item.getItemUnitId(), unit -> item.setItemUnitName(unit.getName()));
            }));
        }
        // 2. å¡«å……关联信息
        fillSaleQuotationRelatedFields(respVO);
        return respVO;
    }
    private PageResult<CrmSaleQuotationRespVO> buildSaleQuotationRespVOPageResult(PageResult<CrmSaleQuotationDO> pageResult) {
        if (CollUtil.isEmpty(pageResult.getList())) {
            return PageResult.empty(pageResult.getTotal());
        }
        // 1. æŠ¥ä»·æ˜Žç»†
        List<CrmSaleQuotationProductDO> productList = saleQuotationService.getSaleQuotationProductListByQuotationIds(
                convertSet(pageResult.getList(), CrmSaleQuotationDO::getId));
        Map<Long, List<CrmSaleQuotationProductDO>> productMap = convertMultiMap(productList, CrmSaleQuotationProductDO::getQuotationId);
        // 1.2 ç‰©æ–™ä¿¡æ¯
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(convertSet(productList, CrmSaleQuotationProductDO::getItemId));
        // 1.3 ç”¨æˆ·ä¿¡æ¯
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(pageResult.getList(), CrmSaleQuotationDO::getOwnerUserId));
        // 1.4 éƒ¨é—¨ä¿¡æ¯
        Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(
                convertSet(pageResult.getList(), CrmSaleQuotationDO::getOwnerUserId));
        // 2. å¼€å§‹æ‹¼æŽ¥
        return BeanUtils.toBean(pageResult, CrmSaleQuotationRespVO.class, vo -> {
            vo.setItems(BeanUtils.toBean(productMap.get(vo.getId()), CrmSaleQuotationRespVO.Item.class,
                    item -> MapUtils.findAndThen(itemMap, item.getItemId(), mdmItem -> item.setItemCode(mdmItem.getCode())
                            .setItemName(mdmItem.getName()).setItemBarCode(mdmItem.getBarCode()))));
            MapUtils.findAndThen(userMap, vo.getOwnerUserId(), user -> vo.setOwnerUserName(user.getNickname()));
            MapUtils.findAndThen(deptMap, vo.getOwnerUserId(), dept -> vo.setOwnerUserDeptName(dept.getName()));
        });
    }
    private void fillSaleQuotationRelatedFields(CrmSaleQuotationRespVO vo) {
        // å¡«å……负责人名称
        if (vo.getOwnerUserId() != null) {
            AdminUserRespDTO user = adminUserApi.getUser(vo.getOwnerUserId());
            if (user != null) {
                vo.setOwnerUserName(user.getNickname());
                // å¡«å……部门名称
                if (user.getDeptId() != null) {
                    DeptRespDTO dept = deptApi.getDept(user.getDeptId());
                    if (dept != null) {
                        vo.setOwnerUserDeptName(dept.getName());
                    }
                }
            }
        }
        // å¡«å……创建人名称
        if (vo.getCreator() != null) {
            try {
                AdminUserRespDTO user = adminUserApi.getUser(Long.valueOf(vo.getCreator()));
                if (user != null) {
                    vo.setCreatorName(user.getNickname());
                }
            } catch (NumberFormatException ignored) {
            }
        }
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.crm.controller.admin.quotation.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@Schema(description = "管理后台 - CRM é”€å”®æŠ¥ä»·å•分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class CrmSaleQuotationPageReqVO extends PageParam {
    @Schema(description = "报价单号", example = "QT001")
    private String no;
    @Schema(description = "报价单名称", example = "报价单A")
    private String name;
    @Schema(description = "客户编号", example = "18336")
    private Long customerId;
    @Schema(description = "商机编号", example = "10864")
    private Long businessId;
    @Schema(description = "场景类型", example = "1")
    @InEnum(CrmSceneTypeEnum.class)
    private Integer sceneType;
    @Schema(description = "审批状态", example = "20")
    @InEnum(CrmSaleQuotationStatusEnum.class)
    private Integer status;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,171 @@
package cn.iocoder.yudao.module.crm.controller.admin.quotation.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - CRM é”€å”®æŠ¥ä»·å• Response VO")
@Data
@ExcelIgnoreUnannotated
public class CrmSaleQuotationRespVO {
    @Schema(description = "报价单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430")
    @ExcelProperty("报价单编号")
    private Long id;
    @Schema(description = "报价单号", requiredMode = Schema.RequiredMode.REQUIRED, example = "QT001")
    @ExcelProperty("报价单号")
    private String no;
    @Schema(description = "报价单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "报价单A")
    @ExcelProperty("报价单名称")
    private String name;
    @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18336")
    @ExcelProperty("客户编号")
    private Long customerId;
    @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户A")
    @ExcelProperty("客户名称")
    private String customerName;
    @Schema(description = "商机编号", example = "10864")
    @ExcelProperty("商机编号")
    private Long businessId;
    @Schema(description = "商机名称", example = "商机A")
    @ExcelProperty("商机名称")
    private String businessName;
    @Schema(description = "联系人编号", example = "18546")
    private Long contactId;
    @Schema(description = "联系人名称", example = "联系人A")
    @ExcelProperty("联系人名称")
    private String contactName;
    @Schema(description = "负责人的用户编号", example = "25682")
    @ExcelProperty("负责人编号")
    private Long ownerUserId;
    @Schema(description = "负责人名字", example = "张三")
    @ExcelProperty("负责人名字")
    private String ownerUserName;
    @Schema(description = "负责人部门")
    @ExcelProperty("负责人部门")
    private String ownerUserDeptName;
    @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "20")
    @ExcelProperty("审批状态")
    private Integer status;
    @Schema(description = "工作流编号", example = "1043")
    private String processInstanceId;
    @Schema(description = "报价日期")
    @ExcelProperty("报价日期")
    private LocalDateTime quotationTime;
    @Schema(description = "有效期至")
    @ExcelProperty("有效期至")
    private LocalDateTime validUntil;
    @Schema(description = "产品总金额", example = "19510")
    @ExcelProperty("产品总金额")
    private BigDecimal totalProductPrice;
    @Schema(description = "整单折扣", example = "10.00")
    @ExcelProperty("整单折扣")
    private BigDecimal discountPercent;
    @Schema(description = "优惠金额", example = "1951")
    @ExcelProperty("优惠金额")
    private BigDecimal discountPrice;
    @Schema(description = "报价总金额", example = "17559")
    @ExcelProperty("报价总金额")
    private BigDecimal totalPrice;
    @Schema(description = "税率", example = "13.00")
    @ExcelProperty("税率")
    private BigDecimal taxRate;
    @Schema(description = "税额", example = "2282")
    @ExcelProperty("税额")
    private BigDecimal taxPrice;
    @Schema(description = "关联合同编号", example = "12345")
    private Long contractId;
    @Schema(description = "关联合同单号", example = "HT001")
    @ExcelProperty("关联合同单号")
    private String contractNo;
    @Schema(description = "附件地址", example = "https://www.baidu.com")
    private String fileUrl;
    @Schema(description = "备注", example = "备注信息")
    @ExcelProperty("备注")
    private String remark;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
    @Schema(description = "创建人", example = "25682")
    private String creator;
    @Schema(description = "创建人名字", example = "张三")
    @ExcelProperty("创建人名字")
    private String creatorName;
    @Schema(description = "物料明细列表")
    private List<Item> items;
    @Schema(description = "物料明细")
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Item {
        @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "888")
        private Long id;
        @Schema(description = "MDM ç‰©æ–™ç¼–号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        private Long itemId;
        @Schema(description = "物料编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "ITEM001")
        private String itemCode;
        @Schema(description = "物料名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "物料A")
        private String itemName;
        @Schema(description = "物料条码", example = "BAR001")
        private String itemBarCode;
        @Schema(description = "计量单位编号", example = "1")
        private Long itemUnitId;
        @Schema(description = "计量单位名称", example = "个")
        private String itemUnitName;
        @Schema(description = "物料原价", requiredMode = Schema.RequiredMode.REQUIRED, example = "100.00")
        private BigDecimal itemPrice;
        @Schema(description = "报价单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
        private BigDecimal quotationPrice;
        @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
        private BigDecimal count;
        @Schema(description = "小计金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1230.00")
        private BigDecimal totalPrice;
        @Schema(description = "税率", example = "13.00")
        private BigDecimal taxPercent;
        @Schema(description = "税额", example = "159.90")
        private BigDecimal taxPrice;
        @Schema(description = "备注", example = "备注")
        private String remark;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/vo/CrmSaleQuotationSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
package cn.iocoder.yudao.module.crm.controller.admin.quotation.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - CRM é”€å”®æŠ¥ä»·å•创建/更新 Request VO")
@Data
public class CrmSaleQuotationSaveReqVO {
    @Schema(description = "报价单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430")
    private Long id;
    @Schema(description = "报价单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "报价单A")
    @NotNull(message = "报价单名称不能为空")
    private String name;
    @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18336")
    @NotNull(message = "客户编号不能为空")
    private Long customerId;
    @Schema(description = "商机编号", example = "10864")
    private Long businessId;
    @Schema(description = "联系人编号", example = "18546")
    private Long contactId;
    @Schema(description = "负责人的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17144")
    @NotNull(message = "负责人不能为空")
    private Long ownerUserId;
    @Schema(description = "报价日期")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime quotationTime;
    @Schema(description = "有效期至")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime validUntil;
    @Schema(description = "整单折扣", example = "10.00")
    private BigDecimal discountPercent;
    @Schema(description = "税率", example = "13.00")
    private BigDecimal taxRate;
    @Schema(description = "附件地址", example = "https://www.baidu.com")
    private String fileUrl;
    @Schema(description = "备注", example = "备注信息")
    private String remark;
    @Schema(description = "物料明细列表", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "物料明细不能为空")
    private List<Item> items;
    @Schema(description = "物料明细")
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Item {
        @Schema(description = "编号", example = "888")
        private Long id;
        @Schema(description = "MDM ç‰©æ–™ç¼–号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529")
        @NotNull(message = "物料编号不能为空")
        private Long itemId;
        @Schema(description = "报价单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00")
        @NotNull(message = "报价单价不能为空")
        private BigDecimal quotationPrice;
        @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
        @NotNull(message = "数量不能为空")
        private BigDecimal count;
        @Schema(description = "税率", example = "13.00")
        private BigDecimal taxPercent;
        @Schema(description = "备注", example = "备注")
        private String remark;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractProductDO.java
@@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.crm.dal.dataobject.contract;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -36,15 +35,21 @@
     */
    private Long contractId;
    /**
     * äº§å“ç¼–号
     * MDM ç‰©æ–™ç¼–号
     *
     * å…³è” {@link CrmProductDO#getId()}
     * å…³è” MdmItemRespDTO.id
     */
    private Long productId;
    private Long itemId;
    /**
     * äº§å“å•价,单位:元
     * è®¡é‡å•位编号
     *
     * å…³è” MdmUnitMeasureRespDTO.id
     */
    private BigDecimal productPrice;
    private Long itemUnitId;
    /**
     * ç‰©æ–™åŽŸä»·ï¼Œå•ä½ï¼šå…ƒï¼ˆå†—ä½™ï¼‰
     */
    private BigDecimal itemPrice;
    /**
     * åˆåŒä»·æ ¼, å•位:元
     */
@@ -56,7 +61,7 @@
    /**
     * æ€»è®¡ä»·æ ¼ï¼Œå•位:元
     *
     * totalPrice = businessPrice * count
     * totalPrice = contractPrice * count
     */
    private BigDecimal totalPrice;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
package cn.iocoder.yudao.module.crm.dal.dataobject.quotation;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO;
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.enums.quotation.CrmSaleQuotationStatusEnum;
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;
/**
 * CRM é”€å”®æŠ¥ä»·å• DO
 *
 * @author èŠ‹é“æºç 
 */
@TableName("crm_sale_quotation")
@KeySequence("crm_sale_quotation_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CrmSaleQuotationDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æŠ¥ä»·å•号
     */
    private String no;
    /**
     * æŠ¥ä»·å•名称
     */
    private String name;
    /**
     * å®¢æˆ·ç¼–号
     *
     * å…³è” {@link CrmCustomerDO#getId()}
     */
    private Long customerId;
    /**
     * å•†æœºç¼–号,非必须
     *
     * å…³è” {@link CrmBusinessDO#getId()}
     */
    private Long businessId;
    /**
     * è”系人编号,非必须
     *
     * å…³è” {@link CrmContactDO#getId()}
     */
    private Long contactId;
    /**
     * è´Ÿè´£äººç”¨æˆ·ç¼–号
     *
     * å…³è” AdminUserDO çš„ id å­—段
     */
    private Long ownerUserId;
    /**
     * å®¡æ‰¹çŠ¶æ€
     *
     * æžšä¸¾ {@link CrmSaleQuotationStatusEnum}
     */
    private Integer status;
    /**
     * å·¥ä½œæµç¼–号
     *
     * å…³è” ProcessInstance çš„ id å±žæ€§
     */
    private String processInstanceId;
    /**
     * æŠ¥ä»·æ—¥æœŸ
     */
    private LocalDateTime quotationTime;
    /**
     * æœ‰æ•ˆæœŸè‡³
     */
    private LocalDateTime validUntil;
    /**
     * äº§å“æ€»é‡‘额,单位:元
     */
    private BigDecimal totalProductPrice;
    /**
     * æ•´å•折扣,百分比
     */
    private BigDecimal discountPercent;
    /**
     * ä¼˜æƒ é‡‘额,单位:元
     */
    private BigDecimal discountPrice;
    /**
     * æŠ¥ä»·æ€»é‡‘额,单位:元
     *
     * totalPrice = totalProductPrice - discountPrice
     */
    private BigDecimal totalPrice;
    /**
     * ç¨ŽçŽ‡ï¼Œç™¾åˆ†æ¯”
     */
    private BigDecimal taxRate;
    /**
     * ç¨Žé¢ï¼Œå•位:元
     */
    private BigDecimal taxPrice;
    /**
     * å…³è”合同编号
     *
     * å…³è” {@link CrmContractDO#getId()}
     */
    private Long contractId;
    /**
     * å…³è”合同单号
     */
    private String contractNo;
    /**
     * é™„件地址
     */
    private String fileUrl;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/quotation/CrmSaleQuotationProductDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.crm.dal.dataobject.quotation;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
/**
 * CRM é”€å”®æŠ¥ä»·å•产品明细 DO
 *
 * @author èŠ‹é“æºç 
 */
@TableName("crm_sale_quotation_product")
@KeySequence("crm_sale_quotation_product_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CrmSaleQuotationProductDO extends BaseDO {
    /**
     * ä¸»é”®
     */
    @TableId
    private Long id;
    /**
     * æŠ¥ä»·å•编号
     *
     * å…³è” {@link CrmSaleQuotationDO#getId()}
     */
    private Long quotationId;
    /**
     * MDM ç‰©æ–™ç¼–号
     *
     * å…³è” MdmItemRespDTO.id
     */
    private Long itemId;
    /**
     * è®¡é‡å•位编号
     *
     * å…³è” MdmUnitMeasureRespDTO.id
     */
    private Long itemUnitId;
    /**
     * ç‰©æ–™åŽŸä»·ï¼Œå•ä½ï¼šå…ƒï¼ˆå†—ä½™ï¼‰
     */
    private BigDecimal itemPrice;
    /**
     * æŠ¥ä»·å•价,单位:元
     */
    private BigDecimal quotationPrice;
    /**
     * æ•°é‡
     */
    private BigDecimal count;
    /**
     * å°è®¡é‡‘额,单位:元
     *
     * totalPrice = quotationPrice * count
     */
    private BigDecimal totalPrice;
    /**
     * ç¨ŽçŽ‡ï¼Œç™¾åˆ†æ¯”
     */
    private BigDecimal taxPercent;
    /**
     * ç¨Žé¢ï¼Œå•位:元
     */
    private BigDecimal taxPrice;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
package cn.iocoder.yudao.module.crm.dal.mysql.quotation;
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.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * CRM é”€å”®æŠ¥ä»·å• Mapper
 *
 * @author èŠ‹é“æºç 
 */
@Mapper
public interface CrmSaleQuotationMapper extends BaseMapperX<CrmSaleQuotationDO> {
    default CrmSaleQuotationDO selectByNo(String no) {
        return selectOne(CrmSaleQuotationDO::getNo, no);
    }
    default PageResult<CrmSaleQuotationDO> selectPage(CrmSaleQuotationPageReqVO pageReqVO, Long userId) {
        MPJLambdaWrapperX<CrmSaleQuotationDO> query = new MPJLambdaWrapperX<>();
        // æ‹¼æŽ¥æ•°æ®æƒé™çš„æŸ¥è¯¢æ¡ä»¶
        CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_SALE_QUOTATION.getType(),
                CrmSaleQuotationDO::getId, userId, pageReqVO.getSceneType());
        // æ‹¼æŽ¥è‡ªèº«çš„æŸ¥è¯¢æ¡ä»¶
        query.selectAll(CrmSaleQuotationDO.class)
                .likeIfPresent(CrmSaleQuotationDO::getNo, pageReqVO.getNo())
                .likeIfPresent(CrmSaleQuotationDO::getName, pageReqVO.getName())
                .eqIfPresent(CrmSaleQuotationDO::getCustomerId, pageReqVO.getCustomerId())
                .eqIfPresent(CrmSaleQuotationDO::getBusinessId, pageReqVO.getBusinessId())
                .eqIfPresent(CrmSaleQuotationDO::getStatus, pageReqVO.getStatus())
                .orderByDesc(CrmSaleQuotationDO::getId);
        return selectJoinPage(pageReqVO, CrmSaleQuotationDO.class, query);
    }
    default PageResult<CrmSaleQuotationDO> selectPageByCustomerId(CrmSaleQuotationPageReqVO pageReqVO) {
        return selectPage(pageReqVO, new LambdaQueryWrapperX<CrmSaleQuotationDO>()
                .eq(CrmSaleQuotationDO::getCustomerId, pageReqVO.getCustomerId())
                .likeIfPresent(CrmSaleQuotationDO::getNo, pageReqVO.getNo())
                .likeIfPresent(CrmSaleQuotationDO::getName, pageReqVO.getName())
                .orderByDesc(CrmSaleQuotationDO::getId));
    }
    default PageResult<CrmSaleQuotationDO> selectPageByBusinessId(CrmSaleQuotationPageReqVO pageReqVO) {
        return selectPage(pageReqVO, new LambdaQueryWrapperX<CrmSaleQuotationDO>()
                .eq(CrmSaleQuotationDO::getBusinessId, pageReqVO.getBusinessId())
                .likeIfPresent(CrmSaleQuotationDO::getNo, pageReqVO.getNo())
                .likeIfPresent(CrmSaleQuotationDO::getName, pageReqVO.getName())
                .orderByDesc(CrmSaleQuotationDO::getId));
    }
    default List<CrmSaleQuotationDO> selectListByCustomerId(Long customerId) {
        return selectList(CrmSaleQuotationDO::getCustomerId, customerId);
    }
    default List<CrmSaleQuotationDO> selectListByBusinessId(Long businessId) {
        return selectList(CrmSaleQuotationDO::getBusinessId, businessId);
    }
    default Long selectCountByBusinessId(Long businessId) {
        return selectCount(CrmSaleQuotationDO::getBusinessId, businessId);
    }
    default Long selectCountByCustomerId(Long customerId) {
        return selectCount(CrmSaleQuotationDO::getCustomerId, customerId);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/quotation/CrmSaleQuotationProductMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.crm.dal.mysql.quotation;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
 * CRM é”€å”®æŠ¥ä»·å•产品明细 Mapper
 *
 * @author èŠ‹é“æºç 
 */
@Mapper
public interface CrmSaleQuotationProductMapper extends BaseMapperX<CrmSaleQuotationProductDO> {
    default List<CrmSaleQuotationProductDO> selectListByQuotationId(Long quotationId) {
        return selectList(new LambdaQueryWrapperX<CrmSaleQuotationProductDO>()
                .eq(CrmSaleQuotationProductDO::getQuotationId, quotationId));
    }
    default List<CrmSaleQuotationProductDO> selectListByQuotationIds(Collection<Long> quotationIds) {
        return selectList(new LambdaQueryWrapperX<CrmSaleQuotationProductDO>()
                .in(CrmSaleQuotationProductDO::getQuotationId, quotationIds));
    }
    default void deleteByQuotationId(Long quotationId) {
        delete(new LambdaQueryWrapperX<CrmSaleQuotationProductDO>()
                .eq(CrmSaleQuotationProductDO::getQuotationId, quotationId));
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/no/CrmNoRedisDAO.java
@@ -29,6 +29,11 @@
     */
    public static final String RECEIVABLE_PREFIX = "HK";
    /**
     * é”€å”®æŠ¥ä»· {@link cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO}
     */
    public static final String SALE_QUOTATION_NO_PREFIX = "QT";
    @Resource
    private StringRedisTemplate stringRedisTemplate;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -109,4 +109,17 @@
    // ========== æ•°æ®ç»Ÿè®¡ 1_020_014_000 ==========
    // ========== é”€å”®æŠ¥ä»· 1_020_015_000 ==========
    ErrorCode SALE_QUOTATION_NOT_EXISTS = new ErrorCode(1_020_015_000, "销售报价单不存在");
    ErrorCode SALE_QUOTATION_NO_EXISTS = new ErrorCode(1_020_015_001, "销售报价单号已存在");
    ErrorCode SALE_QUOTATION_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_020_015_002, "销售报价单({})不是草稿状态,无法修改");
    ErrorCode SALE_QUOTATION_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_020_015_003, "销售报价单({})不是草稿状态,无法删除");
    ErrorCode SALE_QUOTATION_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_020_015_004, "销售报价单不是草稿状态,无法提交审批");
    ErrorCode SALE_QUOTATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_020_015_005, "销售报价单不是审批中状态,无法更新审批状态");
    ErrorCode SALE_QUOTATION_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_020_015_006, "销售报价审批分类不存在,请检查 BPM é…ç½®");
    ErrorCode SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_020_015_007, "销售报价审批流程不存在或未激活,请检查 BPM é…ç½®");
    ErrorCode SALE_QUOTATION_NOT_APPROVE = new ErrorCode(1_020_015_008, "销售报价单未审批通过,无法转合同");
    ErrorCode SALE_QUOTATION_CONTRACT_EXISTS = new ErrorCode(1_020_015_009, "销售报价单已转合同,请勿重复操作");
    ErrorCode SALE_QUOTATION_ITEM_NOT_EXISTS = new ErrorCode(1_020_015_010, "物料不存在或未启用");
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/common/CrmBizTypeEnum.java
@@ -24,7 +24,8 @@
    CRM_CONTRACT(5, "合同"),
    CRM_PRODUCT(6, "产品"),
    CRM_RECEIVABLE(7, "回款"),
    CRM_RECEIVABLE_PLAN(8, "回款计划")
    CRM_RECEIVABLE_PLAN(8, "回款计划"),
    CRM_SALE_QUOTATION(9, "销售报价")
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(CrmBizTypeEnum::getType).toArray(Integer[]::new);
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.crm.enums.quotation;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * CRM é”€å”®æŠ¥ä»·å®¡æ‰¹çŠ¶æ€æžšä¸¾
 *
 * @author èŠ‹é“æºç 
 */
@RequiredArgsConstructor
@Getter
public enum CrmSaleQuotationStatusEnum implements ArrayValuable<Integer> {
    DRAFT(0, "草稿"),
    PROCESS(10, "审批中"),
    APPROVE(20, "已通过"),
    REJECT(30, "已拒绝"),
    CANCEL(40, "已取消"),
    CONVERTED(50, "已转合同");
    private final Integer status;
    private final String name;
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(CrmSaleQuotationStatusEnum::getStatus).toArray(Integer[]::new);
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java
@@ -34,6 +34,8 @@
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderCreateReqDTO;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
@@ -49,6 +51,7 @@
import java.time.LocalDateTime;
import java.util.Collection;
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.*;
@@ -100,6 +103,8 @@
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MdmItemApi mdmItemApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -213,11 +218,19 @@
    }
    private List<CrmContractProductDO> validateContractProducts(List<CrmContractSaveReqVO.Product> list) {
        // 1. æ ¡éªŒäº§å“å­˜åœ¨
        productService.validProductList(convertSet(list, CrmContractSaveReqVO.Product::getProductId));
        // 1. æ ¡éªŒç‰©æ–™å­˜åœ¨ä¸”启用
        List<Long> itemIds = convertSet(list, CrmContractSaveReqVO.Product::getItemId);
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(itemIds);
        // 2. è½¬åŒ–为 CrmContractProductDO åˆ—表
        return convertList(list, o -> BeanUtils.toBean(o, CrmContractProductDO.class,
                item -> item.setTotalPrice(MoneyUtils.priceMultiply(item.getContractPrice(), item.getCount()))));
        return convertList(list, o -> BeanUtils.toBean(o, CrmContractProductDO.class, item -> {
            MdmItemRespDTO mdmItem = itemMap.get(item.getItemId());
            if (mdmItem == null) {
                throw exception(PRODUCT_NOT_EXISTS);
            }
            item.setItemUnitId(mdmItem.getUnitMeasureId());
            item.setItemPrice(mdmItem.getSalesPrice());
            item.setTotalPrice(MoneyUtils.priceMultiply(item.getContractPrice(), item.getCount()));
        }));
    }
    private void calculateTotalPrice(CrmContractDO contract, List<CrmContractProductDO> contractProducts) {
@@ -455,7 +468,7 @@
        List<ErpSaleOrderCreateReqDTO.Item> items = contractProducts.stream()
                .map(cp -> {
                    ErpSaleOrderCreateReqDTO.Item item = new ErpSaleOrderCreateReqDTO.Item();
                    item.setProductId(cp.getProductId());
                    item.setItemId(cp.getItemId());
                    item.setProductPrice(cp.getContractPrice());
                    item.setCount(cp.getCount());
                    return item;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,125 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
 * CRM é”€å”®æŠ¥ä»·å• Service æŽ¥å£
 *
 * @author èŠ‹é“æºç 
 */
public interface CrmSaleQuotationService {
    /**
     * åˆ›å»ºé”€å”®æŠ¥ä»·å•
     *
     * @param createReqVO åˆ›å»ºä¿¡æ¯
     * @param userId      ç”¨æˆ·ç¼–号
     * @return ç¼–号
     */
    Long createSaleQuotation(CrmSaleQuotationSaveReqVO createReqVO, Long userId);
    /**
     * æ›´æ–°é”€å”®æŠ¥ä»·å•
     *
     * @param updateReqVO æ›´æ–°ä¿¡æ¯
     */
    void updateSaleQuotation(CrmSaleQuotationSaveReqVO updateReqVO);
    /**
     * åˆ é™¤é”€å”®æŠ¥ä»·å•
     *
     * @param ids ç¼–号列表
     */
    void deleteSaleQuotation(List<Long> ids);
    /**
     * èŽ·å¾—é”€å”®æŠ¥ä»·å•
     *
     * @param id ç¼–号
     * @return é”€å”®æŠ¥ä»·å•
     */
    CrmSaleQuotationDO getSaleQuotation(Long id);
    /**
     * èŽ·å¾—é”€å”®æŠ¥ä»·å•åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @param userId    ç”¨æˆ·ç¼–号
     * @return é”€å”®æŠ¥ä»·å•分页
     */
    PageResult<CrmSaleQuotationDO> getSaleQuotationPage(CrmSaleQuotationPageReqVO pageReqVO, Long userId);
    /**
     * èŽ·å¾—é”€å”®æŠ¥ä»·å•åˆ†é¡µï¼ˆåŸºäºŽå®¢æˆ·ï¼‰
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return é”€å”®æŠ¥ä»·å•分页
     */
    PageResult<CrmSaleQuotationDO> getSaleQuotationPageByCustomerId(CrmSaleQuotationPageReqVO pageReqVO);
    /**
     * èŽ·å¾—é”€å”®æŠ¥ä»·å•åˆ†é¡µï¼ˆåŸºäºŽå•†æœºï¼‰
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return é”€å”®æŠ¥ä»·å•分页
     */
    PageResult<CrmSaleQuotationDO> getSaleQuotationPageByBusinessId(CrmSaleQuotationPageReqVO pageReqVO);
    /**
     * èŽ·å¾—é”€å”®æŠ¥ä»·å•äº§å“æ˜Žç»†åˆ—è¡¨
     *
     * @param quotationId æŠ¥ä»·å•编号
     * @return äº§å“æ˜Žç»†åˆ—表
     */
    List<CrmSaleQuotationProductDO> getSaleQuotationProductListByQuotationId(Long quotationId);
    /**
     * èŽ·å¾—é”€å”®æŠ¥ä»·å•äº§å“æ˜Žç»†åˆ—è¡¨
     *
     * @param quotationIds æŠ¥ä»·å•编号列表
     * @return äº§å“æ˜Žç»†åˆ—表
     */
    List<CrmSaleQuotationProductDO> getSaleQuotationProductListByQuotationIds(Collection<Long> quotationIds);
    /**
     * æäº¤é”€å”®æŠ¥ä»·å•审批
     *
     * @param id                    æŠ¥ä»·å•编号
     * @param processDefinitionKey  æµç¨‹å®šä¹‰ Key
     * @param userId                ç”¨æˆ·ç¼–号
     */
    void submitSaleQuotation(Long id, String processDefinitionKey, Long userId);
    /**
     * æ›´æ–°é”€å”®æŠ¥ä»·å•审批状态
     *
     * @param id        æŠ¥ä»·å•编号
     * @param bpmResult BPM å®¡æ‰¹ç»“æžœ
     */
    void updateSaleQuotationAuditStatus(Long id, Integer bpmResult);
    /**
     * èŽ·å–é”€å”®æŠ¥ä»·å®¡æ‰¹æµç¨‹åˆ—è¡¨
     *
     * @return æµç¨‹å®šä¹‰åˆ—表
     */
    List<Map<String, Object>> getSaleQuotationApproveProcessDefinitionList();
    /**
     * è½¬ä¸ºåˆåŒ
     *
     * @param id     æŠ¥ä»·å•编号
     * @param userId ç”¨æˆ·ç¼–号
     * @return åˆåŒç¼–号
     */
    Long convertToContract(Long id, Long userId);
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,446 @@
package cn.iocoder.yudao.module.crm.service.quotation;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
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.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum;
import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationProductMapper;
import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService;
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.permission.CrmPermissionService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.repository.ProcessDefinition;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
/**
 * CRM é”€å”®æŠ¥ä»·å• Service å®žçŽ°ç±»
 *
 * @author èŠ‹é“æºç 
 */
@Service
@Validated
@Slf4j
public class CrmSaleQuotationServiceImpl implements CrmSaleQuotationService {
    /**
     * é”€å”®æŠ¥ä»· BPM åˆ†ç±»ç¼–码
     */
    private static final String SALE_QUOTATION_APPROVE_CATEGORY_CODE = "sale_quotation_approve";
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
    @Resource
    private CrmSaleQuotationProductMapper saleQuotationProductMapper;
    @Resource
    private CrmNoRedisDAO noRedisDAO;
    @Resource
    private MdmItemApi mdmItemApi;
    @Resource
    private CrmCustomerService customerService;
    @Resource
    private CrmBusinessService businessService;
    @Resource
    private CrmContactService contactService;
    @Resource
    private CrmPermissionService permissionService;
    @Resource
    private CrmContractService contractService;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createSaleQuotation(CrmSaleQuotationSaveReqVO createReqVO, Long userId) {
        // 1.1 æ ¡éªŒç‰©æ–™é¡¹çš„æœ‰æ•ˆæ€§
        List<CrmSaleQuotationProductDO> quotationProducts = validateQuotationItems(createReqVO.getItems());
        // 1.2 ç”ŸæˆæŠ¥ä»·å•号,并校验唯一性
        String no = noRedisDAO.generate(CrmNoRedisDAO.SALE_QUOTATION_NO_PREFIX);
        if (saleQuotationMapper.selectByNo(no) != null) {
            throw exception(SALE_QUOTATION_NO_EXISTS);
        }
        // 1.3 æ ¡éªŒå…³è”字段
        validateRelationDataExists(createReqVO);
        // 2.1 æ’入报价单
        CrmSaleQuotationDO saleQuotation = BeanUtils.toBean(createReqVO, CrmSaleQuotationDO.class, q -> q
                .setNo(no)
                .setStatus(CrmSaleQuotationStatusEnum.DRAFT.getStatus()));
        calculateTotalPrice(saleQuotation, quotationProducts);
        saleQuotationMapper.insert(saleQuotation);
        // 2.2 æ’入报价明细
        quotationProducts.forEach(p -> p.setQuotationId(saleQuotation.getId()));
        saleQuotationProductMapper.insertBatch(quotationProducts);
        // 2.3 åˆ›å»ºæ•°æ®æƒé™
        permissionService.createPermission(saleQuotation.getId(), userId);
        return saleQuotation.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateSaleQuotation(CrmSaleQuotationSaveReqVO updateReqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(updateReqVO.getId());
        // 1.2 æ ¡éªŒçŠ¶æ€ï¼Œåªæœ‰è‰ç¨¿çŠ¶æ€æ‰èƒ½ä¿®æ”¹
        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_UPDATE_FAIL_NOT_DRAFT, saleQuotation.getNo());
        }
        // 1.3 æ ¡éªŒç‰©æ–™é¡¹çš„æœ‰æ•ˆæ€§
        List<CrmSaleQuotationProductDO> quotationProducts = validateQuotationItems(updateReqVO.getItems());
        // 1.4 æ ¡éªŒå…³è”字段
        validateRelationDataExists(updateReqVO);
        // 2.1 æ›´æ–°æŠ¥ä»·å•
        CrmSaleQuotationDO updateObj = BeanUtils.toBean(updateReqVO, CrmSaleQuotationDO.class);
        calculateTotalPrice(updateObj, quotationProducts);
        saleQuotationMapper.updateById(updateObj);
        // 2.2 æ›´æ–°æŠ¥ä»·æ˜Žç»†
        updateQuotationProductList(updateReqVO.getId(), quotationProducts);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteSaleQuotation(List<Long> ids) {
        // 1. æ ¡éªŒä¸å¤„于非草稿状态
        List<CrmSaleQuotationDO> saleQuotations = saleQuotationMapper.selectByIds(ids);
        if (CollUtil.isEmpty(saleQuotations)) {
            return;
        }
        saleQuotations.forEach(saleQuotation -> {
            if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
                throw exception(SALE_QUOTATION_DELETE_FAIL_NOT_DRAFT, saleQuotation.getNo());
            }
        });
        // 2. éåŽ†åˆ é™¤
        saleQuotations.forEach(saleQuotation -> {
            // 2.1 åˆ é™¤æŠ¥ä»·å•
            saleQuotationMapper.deleteById(saleQuotation.getId());
            // 2.2 åˆ é™¤æŠ¥ä»·æ˜Žç»†
            saleQuotationProductMapper.deleteByQuotationId(saleQuotation.getId());
            // 2.3 åˆ é™¤æ•°æ®æƒé™
            permissionService.deletePermission(saleQuotation.getId());
        });
    }
    private CrmSaleQuotationDO validateSaleQuotationExists(Long id) {
        CrmSaleQuotationDO saleQuotation = saleQuotationMapper.selectById(id);
        if (saleQuotation == null) {
            throw exception(SALE_QUOTATION_NOT_EXISTS);
        }
        return saleQuotation;
    }
    private List<CrmSaleQuotationProductDO> validateQuotationItems(List<CrmSaleQuotationSaveReqVO.Item> list) {
        // 1. æ ¡éªŒç‰©æ–™å­˜åœ¨ä¸”启用
        List<Long> itemIds = convertSet(list, CrmSaleQuotationSaveReqVO.Item::getItemId);
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(itemIds);
        // 2. è½¬æ¢ä¸º CrmSaleQuotationProductDO åˆ—表
        return convertList(list, o -> BeanUtils.toBean(o, CrmSaleQuotationProductDO.class, item -> {
            MdmItemRespDTO mdmItem = itemMap.get(item.getItemId());
            if (mdmItem == null) {
                throw exception(SALE_QUOTATION_ITEM_NOT_EXISTS);
            }
            item.setItemUnitId(mdmItem.getUnitMeasureId());
            item.setItemPrice(mdmItem.getSalesPrice());
            if (item.getQuotationPrice() != null && item.getCount() != null) {
                item.setTotalPrice(MoneyUtils.priceMultiply(item.getQuotationPrice(), item.getCount()));
                if (item.getTaxPercent() != null) {
                    item.setTaxPrice(MoneyUtils.priceMultiplyPercent(item.getTotalPrice(), item.getTaxPercent()));
                }
            }
        }));
    }
    private void validateRelationDataExists(CrmSaleQuotationSaveReqVO reqVO) {
        // 1. æ ¡éªŒå®¢æˆ·
        customerService.validateCustomer(reqVO.getCustomerId());
        // 2. æ ¡éªŒå•†æœºï¼ˆå¯é€‰ï¼‰
        if (reqVO.getBusinessId() != null) {
            businessService.validateBusiness(reqVO.getBusinessId());
        }
        // 3. æ ¡éªŒè”系人(可选)
        if (reqVO.getContactId() != null) {
            contactService.validateContact(reqVO.getContactId());
        }
    }
    private void updateQuotationProductList(Long id, List<CrmSaleQuotationProductDO> newList) {
        // ç¬¬ä¸€æ­¥ï¼Œå¯¹æ¯”新老数据,获得添加、修改、删除的列表
        List<CrmSaleQuotationProductDO> oldList = saleQuotationProductMapper.selectListByQuotationId(id);
        List<List<CrmSaleQuotationProductDO>> diffList = diffList(oldList, newList,
                (oldVal, newVal) -> oldVal.getId().equals(newVal.getId()));
        // ç¬¬äºŒæ­¥ï¼Œæ‰¹é‡æ·»åŠ ã€ä¿®æ”¹ã€åˆ é™¤
        if (CollUtil.isNotEmpty(diffList.get(0))) {
            diffList.get(0).forEach(o -> o.setQuotationId(id));
            saleQuotationProductMapper.insertBatch(diffList.get(0));
        }
        if (CollUtil.isNotEmpty(diffList.get(1))) {
            saleQuotationProductMapper.updateBatch(diffList.get(1));
        }
        if (CollUtil.isNotEmpty(diffList.get(2))) {
            saleQuotationProductMapper.deleteByIds(convertList(diffList.get(2), CrmSaleQuotationProductDO::getId));
        }
    }
    private void calculateTotalPrice(CrmSaleQuotationDO saleQuotation, List<CrmSaleQuotationProductDO> items) {
        saleQuotation.setTotalProductPrice(getSumValue(items, CrmSaleQuotationProductDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO));
        saleQuotation.setTotalTaxPrice(getSumValue(items, CrmSaleQuotationProductDO::getTaxPrice, BigDecimal::add, BigDecimal.ZERO));
        // è®¡ç®—优惠价格
        if (saleQuotation.getDiscountPercent() == null) {
            saleQuotation.setDiscountPercent(BigDecimal.ZERO);
        }
        BigDecimal basePrice = saleQuotation.getTotalProductPrice().add(saleQuotation.getTotalTaxPrice());
        saleQuotation.setDiscountPrice(MoneyUtils.priceMultiplyPercent(basePrice, saleQuotation.getDiscountPercent()));
        saleQuotation.setTotalPrice(basePrice.subtract(saleQuotation.getDiscountPrice()));
    }
    @Override
    public CrmSaleQuotationDO getSaleQuotation(Long id) {
        return saleQuotationMapper.selectById(id);
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPage(CrmSaleQuotationPageReqVO pageReqVO, Long userId) {
        return saleQuotationMapper.selectPage(pageReqVO, userId);
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByCustomerId(CrmSaleQuotationPageReqVO pageReqVO) {
        return saleQuotationMapper.selectPageByCustomerId(pageReqVO);
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByBusinessId(CrmSaleQuotationPageReqVO pageReqVO) {
        return saleQuotationMapper.selectPageByBusinessId(pageReqVO);
    }
    @Override
    public List<CrmSaleQuotationProductDO> getSaleQuotationProductListByQuotationId(Long quotationId) {
        return saleQuotationProductMapper.selectListByQuotationId(quotationId);
    }
    @Override
    public List<CrmSaleQuotationProductDO> getSaleQuotationProductListByQuotationIds(Collection<Long> quotationIds) {
        if (CollUtil.isEmpty(quotationIds)) {
            return Collections.emptyList();
        }
        return saleQuotationProductMapper.selectListByQuotationIds(quotationIds);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitSaleQuotation(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒæŠ¥ä»·å•存在且为草稿状态
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_SUBMIT_FAIL_NOT_DRAFT);
        }
        // 2. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        validateSaleQuotationApproveCategoryAndProcessDefinition();
        // 3. æ ¡éªŒä¼ å…¥çš„æµç¨‹å®šä¹‰ Key æ˜¯å¦å±žäºŽè¯¥åˆ†ç±»ä¸”处于激活状态
        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (processDefinition == null) {
            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        // 4. åˆ›å»ºæµç¨‹å®žä¾‹
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(String.valueOf(id)));
        // 5. æ›´æ–°æŠ¥ä»·å•状态
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(id)
                .setProcessInstanceId(processInstanceId)
                .setStatus(CrmSaleQuotationStatusEnum.PROCESS.getStatus()));
    }
    /**
     * æ ¡éªŒ BPM é”€å”®æŠ¥ä»·å®¡æ‰¹åˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
     */
    private void validateSaleQuotationApproveCategoryAndProcessDefinition() {
        // 1. æ ¡éªŒåˆ†ç±»æ˜¯å¦å­˜åœ¨
        List<String> codes = Collections.singletonList(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
        Map<String, ?> categoryMap = bpmCategoryService.getCategoryMap(codes);
        if (!categoryMap.containsKey(SALE_QUOTATION_APPROVE_CATEGORY_CODE)) {
            throw exception(SALE_QUOTATION_BPM_CATEGORY_NOT_EXISTS);
        }
        // 2. æ ¡éªŒåˆ†ç±»ä¸‹æ˜¯å¦æœ‰å¯ç”¨çš„æµç¨‹å®šä¹‰
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        // 3. æ ¡éªŒæ˜¯å¦æœ‰æ¿€æ´»çŠ¶æ€çš„æµç¨‹å®šä¹‰
        Set<String> processDefinitionIds = convertSet(definitionInfoList, BpmProcessDefinitionInfoDO::getProcessDefinitionId);
        List<ProcessDefinition> processDefinitions = bpmProcessDefinitionService.getProcessDefinitionList(processDefinitionIds);
        boolean hasActiveDefinition = processDefinitions.stream()
                .anyMatch(pd -> !pd.isSuspended());
        if (!hasActiveDefinition) {
            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
    }
    @Override
    public void updateSaleQuotationAuditStatus(Long id, Integer bpmResult) {
        // 1. æ ¡éªŒæŠ¥ä»·å•存在且为审批中状态
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        if (!CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(saleQuotation.getStatus())) {
            log.error("[updateSaleQuotationAuditStatus] æŠ¥ä»·å•({}) ä¸å¤„于审批中", id);
            throw exception(SALE_QUOTATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
        }
        // 2. è½¬æ¢å®¡æ‰¹ç»“æžœ
        Integer auditStatus = convertBpmResultToAuditStatus(bpmResult);
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(id)
                .setStatus(auditStatus));
    }
    /**
     * BPM å®¡æ‰¹ç»“果转换
     */
    private Integer convertBpmResultToAuditStatus(Integer bpmResult) {
        Integer auditStatus = BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)
                ? CrmSaleQuotationStatusEnum.APPROVE.getStatus()
                : BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)
                ? CrmSaleQuotationStatusEnum.REJECT.getStatus()
                : BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)
                ? CrmSaleQuotationStatusEnum.CANCEL.getStatus() : null;
        Assert.notNull(auditStatus, "BPM å®¡æ‰¹ç»“æžœ({}) è½¬æ¢å¤±è´¥", bpmResult);
        return auditStatus;
    }
    @Override
    public List<Map<String, Object>> getSaleQuotationApproveProcessDefinitionList() {
        // 1. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        validateSaleQuotationApproveCategoryAndProcessDefinition();
        // 2. èŽ·å–åˆ†ç±»ä¸‹çš„æµç¨‹å®šä¹‰ä¿¡æ¯
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
        // 3. èŽ·å–æµç¨‹å®šä¹‰è¯¦æƒ…
        Set<String> processDefinitionIds = convertSet(definitionInfoList, BpmProcessDefinitionInfoDO::getProcessDefinitionId);
        List<ProcessDefinition> processDefinitions = bpmProcessDefinitionService.getProcessDefinitionList(processDefinitionIds);
        // 4. è¿‡æ»¤ï¼šåªä¿ç•™æ¿€æ´»çŠ¶æ€ï¼Œä¸”ç›¸åŒ key åªä¿ç•™æœ€æ–°ç‰ˆæœ¬
        Map<String, ProcessDefinition> latestVersionMap = new HashMap<>();
        for (ProcessDefinition pd : processDefinitions) {
            if (pd.isSuspended()) {
                continue;
            }
            ProcessDefinition existing = latestVersionMap.get(pd.getKey());
            if (existing == null || pd.getVersion() > existing.getVersion()) {
                latestVersionMap.put(pd.getKey(), pd);
            }
        }
        // 5. è¿”回流程定义列表
        List<Map<String, Object>> result = new ArrayList<>();
        for (ProcessDefinition pd : latestVersionMap.values()) {
            Map<String, Object> item = new HashMap<>();
            item.put("id", pd.getId());
            item.put("key", pd.getKey());
            item.put("name", pd.getName());
            item.put("version", pd.getVersion());
            result.add(item);
        }
        return result;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long convertToContract(Long id, Long userId) {
        // 1. æ ¡éªŒæŠ¥ä»·å•存在且已审批通过
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_NOT_APPROVE);
        }
        // æ ¡éªŒæ˜¯å¦å·²è½¬åˆåŒ
        if (saleQuotation.getContractId() != null) {
            throw exception(SALE_QUOTATION_CONTRACT_EXISTS);
        }
        // 2. èŽ·å–æŠ¥ä»·æ˜Žç»†
        List<CrmSaleQuotationProductDO> quotationProducts = saleQuotationProductMapper.selectListByQuotationId(id);
        // 3. æž„建合同创建 VO
        CrmContractSaveReqVO contractVO = new CrmContractSaveReqVO();
        contractVO.setName(saleQuotation.getName() + "-合同");
        contractVO.setCustomerId(saleQuotation.getCustomerId());
        contractVO.setBusinessId(saleQuotation.getBusinessId());
        contractVO.setOwnerUserId(saleQuotation.getOwnerUserId());
        contractVO.setOrderDate(saleQuotation.getQuotationTime());
        contractVO.setDiscountPercent(saleQuotation.getDiscountPercent());
        contractVO.setRemark("由报价单[" + saleQuotation.getNo() + "]生成");
        // è½¬æ¢æ˜Žç»†é¡¹ï¼šæŠ¥ä»·å•物料 -> åˆåŒäº§å“
        List<CrmContractSaveReqVO.Product> contractProducts = convertList(quotationProducts, qp -> {
            CrmContractSaveReqVO.Product product = new CrmContractSaveReqVO.Product();
            product.setItemId(qp.getItemId());
            product.setItemPrice(qp.getItemPrice());
            product.setContractPrice(qp.getQuotationPrice());
            product.setCount(qp.getCount().intValue());
            return product;
        });
        contractVO.setProducts(contractProducts);
        // 4. åˆ›å»ºåˆåŒ
        Long contractId = contractService.createContract(contractVO, userId);
        // 5. æ›´æ–°æŠ¥ä»·å•关联信息
        CrmContractDO contract = contractService.getContract(contractId);
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(id)
                .setContractId(contractId)
                .setContractNo(contract.getNo())
                .setStatus(CrmSaleQuotationStatusEnum.CONVERTED.getStatus()));
        return contractId;
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/listener/CrmSaleQuotationStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.crm.service.quotation.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.service.quotation.CrmSaleQuotationService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
/**
 * é”€å”®æŠ¥ä»·å®¡æ‰¹çš„结果监听器实现类
 *
 * é€šè¿‡ businessKey(报价单ID)匹配,而不是固定的流程定义 Key,
 * è¿™æ ·å¯ä»¥æ”¯æŒåˆ†ç±»ä¸‹ä»»æ„ Key çš„æµç¨‹æ¨¡åž‹ã€‚
 *
 * @author èŠ‹é“æºç 
 */
@Component
@Slf4j
public class CrmSaleQuotationStatusListener implements ApplicationListener<BpmProcessInstanceStatusEvent> {
    @Resource
    private CrmSaleQuotationService saleQuotationService;
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
    @Override
    public void onApplicationEvent(BpmProcessInstanceStatusEvent event) {
        // é€šè¿‡ businessKey æŸ¥è¯¢æŠ¥ä»·å•
        Long quotationId;
        try {
            quotationId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException e) {
            return; // businessKey ä¸æ˜¯æ•°å­—,忽略
        }
        CrmSaleQuotationDO saleQuotation = saleQuotationMapper.selectById(quotationId);
        if (saleQuotation == null) {
            return; // ä¸æ˜¯é”€å”®æŠ¥ä»·ï¼Œå¿½ç•¥
        }
        // åªæœ‰å®¡æ‰¹ä¸­çŠ¶æ€çš„æŠ¥ä»·å•æ‰å¤„ç†
        if (!CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(saleQuotation.getStatus())) {
            log.warn("[onApplicationEvent] é”€å”®æŠ¥ä»·å•({}) ä¸å¤„于审批中状态,当前状态: {}", quotationId, saleQuotation.getStatus());
            return;
        }
        log.info("[onApplicationEvent] é”€å”®æŠ¥ä»·å•({}) å®¡æ‰¹çŠ¶æ€æ›´æ–°: {}", quotationId, event.getStatus());
        // æ›´æ–°å®¡æ‰¹çŠ¶æ€
        saleQuotationService.updateSaleQuotationAuditStatus(quotationId, event.getStatus());
    }
}