2026-07-03 3179fbb9c77eeb5260593e9df20046285dc56fe6
1.销售模块修改
2.CRM模块修改
已添加4个文件
已修改23个文件
已删除1个文件
1397 ■■■■ 文件已修改
.claude/rules/compile-check.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/contract_approval_change.md 271 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/contract_product_field_change.md 211 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_erp_integration_frontend.md 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/api/contract/CrmContractApi.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/api/contract/CrmContractApiImpl.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/api/report/CrmReportApiImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java 37 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractDO.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/common/CrmAuditStatusEnum.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationStatusEnum.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/listener/CrmContractStatusListener.java 45 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/listener/CrmSaleQuotationStatusListener.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpAuditStatus.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/rules/compile-check.md
@@ -13,6 +13,8 @@
| CRM æ¨¡å— | MdmItemApi | MDM ç‰©æ–™æŽ¥å£ |
| CRM æ¨¡å— | MdmUnitMeasureApi | MDM è®¡é‡å•位接口 |
| CRM æ¨¡å— | BpmProcessInstanceApi | BPM æµç¨‹å®žä¾‹æŽ¥å£ |
| CRM æ¨¡å— | BpmCategoryService | BPM åˆ†ç±»æœåŠ¡ |
| CRM æ¨¡å— | BpmProcessDefinitionService | BPM æµç¨‹å®šä¹‰æœåŠ¡ |
| ERP æ¨¡å— | CrmCustomerApi | CRM å®¢æˆ·æŽ¥å£ |
| ERP æ¨¡å— | MdmItemApi | MDM ç‰©æ–™æŽ¥å£ |
docs/contract_approval_change.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,271 @@
# åˆåŒå®¡æ‰¹æµç¨‹å˜æ›´
## å˜æ›´è¯´æ˜Ž
1. **销售报价取消 BPM å®¡æ ¸**:销售报价单不再走审批流程,简化为草稿 â†’ å·²è½¬åˆåŒä¸¤ç§çŠ¶æ€
2. **合同审批动态选择流程**:创建合同后,需用户手动选择审批流程并提交审批(类似采购申请)
---
## æ•°æ®åº“变更 SQL
```sql
-- =============================================
-- 1. åˆåŒäº§å“è¡¨å­—段变更(移除旧字段)
-- =============================================
ALTER TABLE `crm_contract_product` DROP COLUMN `product_id`;
ALTER TABLE `crm_contract_product` DROP COLUMN `product_price`;
-- =============================================
-- 2. BPM åˆ†ç±»åˆ›å»ºï¼ˆåˆåŒå®¡æ‰¹ï¼‰
-- =============================================
INSERT INTO `bpm_category` (`name`, `code`, `description`, `status`, `sort`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES ('合同审批', 'contract_approve', '合同审批流程分类', 0, 0, '1', NOW(), '1', NOW(), b'0');
-- =============================================
-- 3. é”€å”®æŠ¥ä»·çŠ¶æ€å­—æ®µæ›´æ–°ï¼ˆç®€åŒ–çŠ¶æ€ï¼‰
-- =============================================
-- çŠ¶æ€å€¼å˜æ›´ï¼š0=草稿, 10=已转合同
-- åŽŸ status å­—段值需要清理:10(审批中)、20(已通过)、30(已拒绝)、40(已取消) æ”¹ä¸º 0(草稿)
UPDATE `crm_sale_quotation` SET `status` = 0 WHERE `status` IN (10, 20, 30, 40);
-- å·²è½¬åˆåŒçš„保持不变(status=50 æ”¹ä¸º status=10)
UPDATE `crm_sale_quotation` SET `status` = 10 WHERE `status` = 50;
-- =============================================
-- 4. åˆåŒå®¡æ‰¹çŠ¶æ€å­—æ®µæ›´æ–°ï¼ˆè®¾ç½®é»˜è®¤å€¼ï¼‰
-- =============================================
-- å°† audit_status ä¸º NULL çš„记录设置为 0(未提交)
UPDATE `crm_contract` SET `audit_status` = 0 WHERE `audit_status` IS NULL;
-- =============================================
-- 4. æŠ¥ä»·å•产品明细表(如果表不存在需创建,已存在则补充字段)
-- =============================================
-- å¦‚æžœ crm_sale_quotation_product è¡¨ä¸å­˜åœ¨ï¼Œæ‰§è¡Œåˆ›å»º
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 DEFAULT 0 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 é”€å”®æŠ¥ä»·å•产品明细';
-- å¦‚果表已存在但缺少 item_price å­—段,执行补充
-- ALTER TABLE `crm_sale_quotation_product`
-- ADD COLUMN `item_price` decimal(24,6) NOT NULL DEFAULT 0 COMMENT '物料原价(冗余)' AFTER `item_unit_id`;
```
---
## æ¶‰åŠé¡µé¢
- é”€å”®æŠ¥ä»·å•列表页面
- é”€å”®æŠ¥ä»·å•表单页面
- åˆåŒåˆ—表页面
- åˆåŒè¯¦æƒ…页面
---
## API å˜æ›´
### åˆåŒæŽ¥å£æ–°å¢ž
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /crm/contract/approve-process-list | èŽ·å–åˆåŒå®¡æ‰¹æµç¨‹åˆ—è¡¨ |
| PUT | /crm/contract/submit | æäº¤åˆåŒå®¡æ‰¹ï¼ˆéœ€ä¼  processDefinitionKey) |
**submit æŽ¥å£å‚数变更:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | åˆåŒç¼–号 |
| processDefinitionKey | String | æ˜¯ | æµç¨‹å®šä¹‰ Key(用户选择) |
### é”€å”®æŠ¥ä»·å•接口变更
#### ç§»é™¤çš„æŽ¥å£
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| PUT | /crm/sale-quotation/submit | æäº¤å®¡æ‰¹ï¼ˆå·²ç§»é™¤ï¼‰ |
| GET | /crm/sale-quotation/approve-process-list | èŽ·å–å®¡æ‰¹æµç¨‹åˆ—è¡¨ï¼ˆå·²ç§»é™¤ï¼‰ |
#### çŠ¶æ€å˜æ›´
| æ—§çŠ¶æ€å€¼ | æ—§çŠ¶æ€å | æ–°çŠ¶æ€å€¼ | æ–°çŠ¶æ€å |
|----------|----------|----------|----------|
| 0 | è‰ç¨¿ | 0 | è‰ç¨¿ |
| 10 | å®¡æ‰¹ä¸­ | - | å·²ç§»é™¤ |
| 20 | å·²é€šè¿‡ | - | å·²ç§»é™¤ |
| 30 | å·²æ‹’绝 | - | å·²ç§»é™¤ |
| 40 | å·²å–消 | - | å·²ç§»é™¤ |
| 50 | å·²è½¬åˆåŒ | 10 | å·²è½¬åˆåŒ |
---
## å‰ç«¯ä¿®æ”¹ç‚¹
### 1. åˆåŒåˆ—表页面 - æäº¤å®¡æ‰¹
**新增提交审批弹窗:**
```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 { getContractApproveProcessList, submitContract } from '@/api/crm/contract'
methods: {
  async handleSubmit(row) {
    const { data } = await getContractApproveProcessList()
    this.processList = data
    this.currentRow = row
    this.selectedProcessKey = null
    this.submitDialogVisible = true
  },
  async confirmSubmit() {
    if (!this.selectedProcessKey) {
      this.$message.warning('请选择审批流程')
      return
    }
    this.submitLoading = true
    try {
      await submitContract(this.currentRow.id, this.selectedProcessKey)
      this.$message.success('提交成功')
      this.submitDialogVisible = false
      this.getList()
    } finally {
      this.submitLoading = false
    }
  }
}
```
**操作按钮调整:**
```html
<el-table-column label="操作" width="280">
  <template #default="{ row }">
    <el-button v-if="row.auditStatus === 0" type="primary" size="small" @click="handleUpdate(row)">编辑</el-button>
    <el-button v-if="row.auditStatus === 0" type="success" size="small" @click="handleSubmit(row)">提交审批</el-button>
    <el-button v-if="row.auditStatus === 0" type="danger" size="small" @click="handleDelete(row)">删除</el-button>
    <el-button v-if="row.auditStatus === 20" type="warning" size="small" @click="handleGenerateOrder(row)">生成订单</el-button>
  </template>
</el-table-column>
```
### 2. åˆåŒçŠ¶æ€æ˜¾ç¤º
```html
<!-- åˆåŒå®¡æ‰¹çŠ¶æ€ -->
<el-tag v-if="row.auditStatus === 0" type="info">未提交</el-tag>
<el-tag v-if="row.auditStatus === 10" type="warning">审批中</el-tag>
<el-tag v-if="row.auditStatus === 20" type="success">审核通过</el-tag>
<el-tag v-if="row.auditStatus === 30" type="danger">审核不通过</el-tag>
```
### 3. é”€å”®æŠ¥ä»·å•列表页面
**移除审批相关操作按钮:**
```html
<!-- ç§»é™¤ä»¥ä¸‹æŒ‰é’® -->
<!-- <el-button v-if="row.status === 0" type="success" size="small" @click="handleSubmit(row)">提交审批</el-button> -->
<!-- ä¿ç•™æŒ‰é’® -->
<el-button v-if="row.status === 0" type="primary" size="small" @click="handleUpdate(row)">编辑</el-button>
<el-button v-if="row.status === 0" type="warning" size="small" @click="handleConvertContract(row)">转合同</el-button>
<el-button v-if="row.status === 0" type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<el-button v-if="row.contractId" type="primary" size="small" @click="handleViewContract(row)">查看合同</el-button>
```
### 4. æŠ¥ä»·å•状态显示
```html
<!-- çŠ¶æ€æ ‡ç­¾ -->
<el-tag v-if="row.status === 0" type="info">草稿</el-tag>
<el-tag v-if="row.status === 10" type="success">已转合同</el-tag>
```
### 5. API æ–‡ä»¶ä¿®æ”¹
```js
// /src/api/crm/contract.js
// æ–°å¢ž API
export function getContractApproveProcessList() {
  return request({ url: '/crm/contract/approve-process-list', method: 'get' })
}
export function submitContract(id, processDefinitionKey) {
  return request({ url: '/crm/contract/submit', method: 'put', params: { id, processDefinitionKey } })
}
// /src/api/crm/saleQuotation.js
// ç§»é™¤ä»¥ä¸‹ API
// export function submitSaleQuotation(id, processDefinitionKey) { ... }
// export function getSaleQuotationApproveProcessList() { ... }
// ä¿ç•™ä»¥ä¸‹ API
export function createSaleQuotation(data) { ... }
export function updateSaleQuotation(data) { ... }
export function deleteSaleQuotation(ids) { ... }
export function getSaleQuotation(id) { ... }
export function getSaleQuotationPage(params) { ... }
export function convertSaleQuotationToContract(id) { ... }
```
---
## ä¸šåŠ¡æµç¨‹å˜æ›´
### æ–°æµç¨‹
```
报价单(草稿) â†’ è½¬åˆåŒ â†’ åˆåŒ(草稿) â†’ ç”¨æˆ·é€‰æ‹©å®¡æ‰¹æµç¨‹ â†’ æäº¤å®¡æ‰¹ â†’ BPM审批 â†’ å®¡æ‰¹é€šè¿‡ â†’ ç”Ÿæˆè®¢å•
```
**流程说明:**
1. é”€å”®æŠ¥ä»·ä¸å†éœ€è¦å®¡æ‰¹ï¼Œè‰ç¨¿çŠ¶æ€å¯ç›´æŽ¥è½¬åˆåŒ
2. åˆåŒåˆ›å»ºåŽä¸ºè‰ç¨¿çŠ¶æ€ï¼Œéœ€ç”¨æˆ·æ‰‹åŠ¨é€‰æ‹©å®¡æ‰¹æµç¨‹å¹¶æäº¤
3. ç‚¹å‡»"提交审批"按钮后,弹出流程选择框,用户选择流程后提交
---
## æ³¨æ„äº‹é¡¹
1. **合同审批 BPM åˆ†ç±»**:需要在 BPM ç®¡ç†åŽå°åˆ›å»ºåˆ†ç±»ç¼–码为 `contract_approve` çš„分类
2. **合同审批流程**:需要在该分类下配置至少一个激活的审批流程
3. **流程选择**:如果只有一个流程,前端可以直接提交;如果有多个流程,需要用户选择
4. **转合同后**:报价转合同后,合同为草稿状态,需手动提交审批
docs/contract_product_field_change.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,211 @@
# åˆåŒæ¨¡å—产品字段变更
## å˜æ›´è¯´æ˜Ž
合同模块的产品来源从 **CRM äº§å“** æ”¹ä¸º **MDM ç‰©æ–™**,涉及字段名称变更。
---
## æ•°æ®åº“变更 SQL
```sql
-- =============================================
-- åˆåŒäº§å“è¡¨å­—段变更
-- =============================================
-- 1. åˆ é™¤æ—§å­—段(如果有数据请先备份)
ALTER TABLE `crm_contract_product` DROP COLUMN `product_id`;
ALTER TABLE `crm_contract_product` DROP COLUMN `product_price`;
-- 2. å¦‚果新字段不存在,则添加(根据实际情况判断是否需要执行)
-- ALTER TABLE `crm_contract_product` ADD COLUMN `item_id` bigint DEFAULT NULL COMMENT 'MDM物料编号' AFTER `contract_id`;
-- ALTER TABLE `crm_contract_product` ADD COLUMN `item_unit_id` bigint DEFAULT NULL COMMENT '计量单位编号' AFTER `item_id`;
-- ALTER TABLE `crm_contract_product` ADD COLUMN `item_price` decimal(24,6) DEFAULT NULL COMMENT '物料原价' AFTER `item_unit_id`;
```
---
## æ¶‰åŠé¡µé¢
- åˆåŒè¡¨å•页面(新建/编辑合同)
- åˆåŒè¯¦æƒ…页面
---
## API å˜æ›´
### 1. åˆ›å»º/更新合同接口
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /crm/contract/create | åˆ›å»ºåˆåŒ |
| PUT | /crm/contract/update | æ›´æ–°åˆåŒ |
### 2. è¯·æ±‚参数变更(products å­—段)
#### æ—§å­—段(已废弃)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| productId | Long | æ˜¯ | CRM äº§å“ç¼–号 |
| productPrice | BigDecimal | å¦ | äº§å“ä»·æ ¼ |
| sellingPrice | BigDecimal | å¦ | é”€å”®ä»·ï¼ˆç”¨ä½œåˆåŒä»·ï¼‰ |
#### æ–°å­—段
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| itemId | Long | æ˜¯ | MDM ç‰©æ–™ç¼–号 |
| itemPrice | BigDecimal | æ˜¯ | ç‰©æ–™åŽŸä»· |
| contractPrice | BigDecimal | æ˜¯ | åˆåŒä»·æ ¼ |
| count | Integer | æ˜¯ | æ•°é‡ |
### 3. è¯·æ±‚示例
**旧格式(不再支持):**
```json
{
  "name": "合同名称",
  "customerId": 1,
  "products": [
    {
      "productId": 4,
      "sellingPrice": 1111,
      "count": 10
    }
  ]
}
```
**新格式:**
```json
{
  "name": "合同名称",
  "customerId": 1,
  "products": [
    {
      "itemId": 4,
      "itemPrice": 1000,
      "contractPrice": 1111,
      "count": 10
    }
  ]
}
```
---
## å“åº”变更
### åˆåŒè¯¦æƒ…接口响应
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /crm/contract/get | èŽ·å–åˆåŒè¯¦æƒ… |
#### äº§å“æ˜Žç»†å­—段变更
| æ—§å­—段 | æ–°å­—段 | è¯´æ˜Ž |
|--------|--------|------|
| productId | itemId | MDM ç‰©æ–™ç¼–号 |
| productName | itemName | ç‰©æ–™åç§° |
| productNo | itemCode | ç‰©æ–™ç¼–码 |
| productUnit | itemUnitName | è®¡é‡å•位名称 |
| productPrice | itemPrice | ç‰©æ–™åŽŸä»· |
| - | itemBarCode | ç‰©æ–™æ¡ç ï¼ˆæ–°å¢žï¼‰ |
---
## å‰ç«¯ä¿®æ”¹ç‚¹
### 1. ç‰©æ–™é€‰æ‹©ç»„ä»¶
将 **CRM äº§å“é€‰æ‹©å™¨** æ”¹ä¸º **MDM ç‰©æ–™é€‰æ‹©å™¨**。
```html
<!-- æ—§ï¼šCRM äº§å“é€‰æ‹©å™¨ -->
<CrmProductSelect v-model="product.productId" />
<!-- æ–°ï¼šMDM ç‰©æ–™é€‰æ‹©å™¨ -->
<MdmItemSelect v-model="product.itemId" @change="handleItemChange" />
```
### 2. ç‰©æ–™é€‰æ‹©å›žè°ƒ
```js
methods: {
  handleItemChange(item) {
    // é€‰æ‹©ç‰©æ–™åŽè‡ªåŠ¨å¡«å……ä»·æ ¼ä¿¡æ¯
    this.product.itemId = item.id
    this.product.itemPrice = item.salesPrice  // ç‰©æ–™åŽŸä»·
    this.product.contractPrice = item.salesPrice  // åˆåŒä»·æ ¼ï¼ˆé»˜è®¤ç­‰äºŽåŽŸä»·ï¼Œå¯ä¿®æ”¹ï¼‰
  }
}
```
### 3. è¡¨æ ¼åˆ—字段修改
```html
<!-- äº§å“æ˜Žç»†è¡¨æ ¼ -->
<el-table-column prop="itemCode" label="物料编码" />
<el-table-column prop="itemName" label="物料名称" />
<el-table-column prop="itemBarCode" label="物料条码" />
<el-table-column prop="itemUnitName" label="单位" />
<el-table-column prop="itemPrice" label="原价" />
<el-table-column prop="contractPrice" label="合同价" />
<el-table-column prop="count" label="数量" />
```
### 4. data æ•°æ®ä¿®æ”¹
```js
// äº§å“æ˜Žç»†æ•°æ®ç»“æž„
product: {
  itemId: null,          // MDM ç‰©æ–™ç¼–号
  itemPrice: null,       // ç‰©æ–™åŽŸä»·
  contractPrice: null,   // åˆåŒä»·æ ¼
  count: null            // æ•°é‡
}
```
---
## MDM ç‰©æ–™é€‰æ‹©å™¨ API
### èŽ·å–ç‰©æ–™åˆ—è¡¨
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /mdm/item/page | åˆ†é¡µæŸ¥è¯¢ç‰©æ–™ |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| code | String | å¦ | ç‰©æ–™ç¼–码 |
| name | String | å¦ | ç‰©æ–™åç§° |
| barCode | String | å¦ | ç‰©æ–™æ¡ç  |
| status | Integer | å¦ | çŠ¶æ€ï¼ˆ0启用 1禁用),传 0 |
**响应字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | Long | ç‰©æ–™ç¼–号(即 itemId) |
| code | String | ç‰©æ–™ç¼–码 |
| name | String | ç‰©æ–™åç§° |
| barCode | String | ç‰©æ–™æ¡ç  |
| salesPrice | BigDecimal | é”€å”®ä»· |
| unitMeasureId | Long | è®¡é‡å•位编号 |
| unitMeasureName | String | è®¡é‡å•位名称 |
---
## æ³¨æ„äº‹é¡¹
1. **物料必须先启用**:只能选择状态为"启用"的 MDM ç‰©æ–™
2. **价格可修改**:`contractPrice` é»˜è®¤ç­‰äºŽç‰©æ–™åŽŸä»·ï¼Œä½†å¯ä»¥æ‰‹åŠ¨ä¿®æ”¹
3. **过渡期**:后端暂时兼容 `productId` å’Œ `sellingPrice` å­—段,但后续会移除兼容逻辑
4. **其他模块同步修改**:销售报价单也使用 MDM ç‰©æ–™ï¼Œå­—段与合同一致
docs/crm_erp_integration_frontend.md
@@ -698,6 +698,10 @@
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`;
-- æ–°å¢žå®šé‡‘金额字段
ALTER TABLE `crm_contract`
ADD COLUMN `deposit_price` decimal(24,6) DEFAULT NULL COMMENT '定金金额,单位:元' AFTER `total_price`;
```
### 5.2 CRM å•†æœºè¡¨
@@ -1006,3 +1010,198 @@
|------|------|
| `docs/crm_erp_integration_frontend.md` | æœ¬æ–‡æ¡£ |
| `docs/crm_erp_integration_sql.sql` | SQL å˜æ›´è„šæœ¬ |
---
## åäºŒã€é”€å”®è®¢å•作废功能(新增)
### 12.1 åŠŸèƒ½è¯´æ˜Ž
销售订单作废功能用于标记已审核的订单为"已作废"状态,实现订单全流程可追溯。
**业务规则:**
- åªæœ‰å·²å®¡æ ¸çš„订单才能作废
- å·²å‡ºåº“或已退货的订单不能作废
- ä½œåºŸåŽè®¢å•状态变为"已作废"
- ä½œåºŸåŽåŒæ­¥æ›´æ–°å…³è”合同状态为"已作废"
- ç®¡ç†å‘˜å¯ä»¥é‡æ–°å¯ç”¨å·²ä½œåºŸçš„订单
### 12.2 çŠ¶æ€æžšä¸¾
| çŠ¶æ€å€¼ | æžšä¸¾ | è¯´æ˜Ž |
|--------|------|------|
| 10 | PROCESS | æœªå®¡æ ¸ |
| 20 | APPROVE | å·²å®¡æ ¸ |
| 30 | CANCEL | å·²ä½œåºŸ |
### 12.3 åˆåŒçŠ¶æ€åŒæ­¥
当销售订单作废/启用时,会自动同步关联合同的状态:
| æ“ä½œ | è®¢å•状态变化 | åˆåŒçŠ¶æ€å˜åŒ– |
|------|--------------|--------------|
| ä½œåºŸè®¢å• | å·²å®¡æ ¸ â†’ å·²ä½œåºŸ | å®¡æ ¸é€šè¿‡ â†’ å·²ä½œåºŸ |
| å¯ç”¨è®¢å• | å·²ä½œåºŸ â†’ å·²å®¡æ ¸ | å·²ä½œåºŸ â†’ å®¡æ ¸é€šè¿‡ |
**合同状态枚举:**
| çŠ¶æ€å€¼ | æžšä¸¾ | è¯´æ˜Ž |
|--------|------|------|
| 0 | DRAFT | æœªæäº¤ |
| 10 | PROCESS | å®¡æ‰¹ä¸­ |
| 20 | APPROVE | å®¡æ ¸é€šè¿‡ |
| 30 | REJECT | å®¡æ ¸ä¸é€šè¿‡ |
| 40 | CANCEL | å·²å–消 |
| 50 | INVALID | å·²ä½œåºŸ |
### 12.4 API æŽ¥å£
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| PUT | /erp/sale-order/cancel | ä½œåºŸé”€å”®è®¢å• |
| PUT | /erp/sale-order/enable | é‡æ–°å¯ç”¨é”€å”®è®¢å• |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | é”€å”®è®¢å•编号 |
**响应:**
```json
{
  "code": 0,
  "msg": "操作成功"
}
```
**错误码:**
| é”™è¯¯ç  | è¯´æ˜Ž |
|--------|------|
| 1_020_201_011 | ä½œåºŸå¤±è´¥ï¼Œè®¢å•未审核 |
| 1_020_201_012 | ä½œåºŸå¤±è´¥ï¼Œå·²å­˜åœ¨å¯¹åº”的销售出库单 |
| 1_020_201_013 | ä½œåºŸå¤±è´¥ï¼Œå·²å­˜åœ¨å¯¹åº”的销售退货单 |
| 1_020_201_014 | ä½œåºŸå¤±è´¥ï¼Œè¯·åˆ·æ–°é¡µé¢åŽé‡è¯• |
| 1_020_201_015 | å¯ç”¨å¤±è´¥ï¼Œè®¢å•未作废 |
| 1_020_201_016 | å¯ç”¨å¤±è´¥ï¼Œè¯·åˆ·æ–°é¡µé¢åŽé‡è¯• |
### 12.5 å‰ç«¯ä¿®æ”¹ç‚¹
**文件路径:** `/src/views/erp/sale/order/index.vue`
**操作列按钮:**
```html
<el-table-column label="操作" width="280">
  <template #default="{ row }">
    <el-button v-if="row.status === 10" type="primary" size="small" @click="handleUpdate(row)">编辑</el-button>
    <el-button v-if="row.status === 10" type="success" size="small" @click="handleApprove(row)">审核</el-button>
    <el-button v-if="row.status === 20" type="warning" size="small" @click="handleProcess(row)">反审核</el-button>
    <el-button v-if="row.status === 20" type="danger" size="small" @click="handleCancel(row)">作废</el-button>
    <el-button v-if="row.status === 30" type="success" size="small" @click="handleEnable(row)">启用</el-button>
    <el-button v-if="row.status === 10" type="danger" size="small" @click="handleDelete(row)">删除</el-button>
  </template>
</el-table-column>
```
**状态标签:**
```html
<el-table-column label="状态" prop="status" width="100">
  <template #default="{ row }">
    <el-tag v-if="row.status === 10" type="warning">未审核</el-tag>
    <el-tag v-if="row.status === 20" type="success">已审核</el-tag>
    <el-tag v-if="row.status === 30" type="info">已作废</el-tag>
  </template>
</el-table-column>
```
**关联合同列(作废时显示红色):**
```html
<el-table-column label="关联合同" prop="contractNo" width="150">
  <template #default="{ row }">
    <el-link
      v-if="row.contractId"
      :type="row.status === 30 ? 'danger' : 'primary'"
      @click="handleViewContract(row)"
    >
      {{ row.contractNo }}
    </el-link>
    <span v-else>-</span>
  </template>
</el-table-column>
```
**JS æ–¹æ³•:**
```js
import { cancelSaleOrder, enableSaleOrder } from '@/api/erp/saleOrder'
methods: {
  async handleCancel(row) {
    try {
      await this.$confirm('确认作废该销售订单?作废后关联合同也将标记为已作废!', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      })
      await cancelSaleOrder(row.id)
      this.$message.success('作废成功')
      this.getList()
    } catch (e) {
      if (e !== 'cancel') {
        this.$message.error(e.message || '作废失败')
      }
    }
  },
  async handleEnable(row) {
    try {
      await this.$confirm('确认重新启用该销售订单?启用后关联合同将恢复为审核通过状态!', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'info'
      })
      await enableSaleOrder(row.id)
      this.$message.success('启用成功')
      this.getList()
    } catch (e) {
      if (e !== 'cancel') {
        this.$message.error(e.message || '启用失败')
      }
    }
  }
}
```
**API å®šä¹‰æ–‡ä»¶ï¼š** `/src/api/erp/saleOrder.js`
```js
// ä½œåºŸé”€å”®è®¢å•
export function cancelSaleOrder(id) {
  return request({
    url: '/erp/sale-order/cancel',
    method: 'put',
    params: { id }
  })
}
// é‡æ–°å¯ç”¨é”€å”®è®¢å•
export function enableSaleOrder(id) {
  return request({
    url: '/erp/sale-order/enable',
    method: 'put',
    params: { id }
  })
}
```
### 12.6 æ³¨æ„äº‹é¡¹
1. ä½œåºŸæ“ä½œä¼šåŒæ­¥æ›´æ–°å…³è”合同状态为"已作废"
2. å¯ç”¨æ“ä½œä¼šåŒæ­¥æ›´æ–°å…³è”合同状态为"审核通过"
3. å·²å‡ºåº“/已退货的订单不能作废,需先处理相关业务单据
4. ä½œåºŸåŽè®¢å•号显示为红色,提醒用户该订单已失效
5. å¯ç”¨åŠŸèƒ½éœ€è¦ç®¡ç†å‘˜æƒé™ï¼ˆ`erp:sale-order:enable`)
yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/api/contract/CrmContractApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.crm.api.contract;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
/**
 * CRM åˆåŒ API æŽ¥å£
 *
 * @author èŠ‹é“æºç 
 */
public interface CrmContractApi {
    String PREFIX = "/rpc-api/crm/contract";
    /**
     * æ›´æ–°åˆåŒçŠ¶æ€
     *
     * @param id     åˆåŒç¼–号
     * @param status çŠ¶æ€
     */
    CommonResult<Boolean> updateContractStatus(Long id, Integer status);
    /**
     * æ ¹æ®é”€å”®è®¢å•编号更新合同状态
     *
     * @param orderId é”€å”®è®¢å•编号
     * @param status  çŠ¶æ€
     */
    CommonResult<Boolean> updateContractStatusByOrderId(Long orderId, Integer status);
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/api/contract/CrmContractApiImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.crm.api.contract;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * CRM åˆåŒ API å®žçŽ°ç±»
 *
 * @author èŠ‹é“æºç 
 */
@RestController
@RequestMapping(CrmContractApi.PREFIX)
@Validated
public class CrmContractApiImpl implements CrmContractApi {
    @Resource
    @Lazy // å»¶è¿ŸåŠ è½½ï¼Œé¿å…å¾ªçŽ¯ä¾èµ–
    private CrmContractService contractService;
    @Override
    public CommonResult<Boolean> updateContractStatus(Long id, Integer status) {
        contractService.updateContractStatus(id, status);
        return success(true);
    }
    @Override
    public CommonResult<Boolean> updateContractStatusByOrderId(Long orderId, Integer status) {
        contractService.updateContractStatusByOrderId(orderId, status);
        return success(true);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/api/report/CrmReportApiImpl.java
@@ -16,6 +16,7 @@
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -47,6 +48,7 @@
    @Resource
    private CrmContractMapper contractMapper;
    @Resource
    @Lazy // å»¶è¿ŸåŠ è½½ï¼Œé¿å…å¾ªçŽ¯ä¾èµ–
    private CrmReceivableService receivableService;
    @Resource
    private AdminUserApi adminUserApi;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
@@ -176,11 +176,19 @@
    @PutMapping("/submit")
    @Operation(summary = "提交合同审批")
    @PreAuthorize("@ss.hasPermission('crm:contract:update')")
    public CommonResult<Boolean> submitContract(@RequestParam("id") Long id) {
        contractService.submitContract(id, getLoginUserId());
    public CommonResult<Boolean> submitContract(@RequestParam("id") Long id,
                                                  @RequestParam("processDefinitionKey") String processDefinitionKey) {
        contractService.submitContract(id, processDefinitionKey, getLoginUserId());
        return success(true);
    }
    @GetMapping("/approve-process-list")
    @Operation(summary = "获取合同审批流程列表")
    @PreAuthorize("@ss.hasPermission('crm:contract:query')")
    public CommonResult<List<Map<String, Object>>> getContractApproveProcessList() {
        return success(contractService.getContractApproveProcessDefinitionList());
    }
    private List<CrmContractRespVO> buildContractDetailList(List<CrmContractDO> contractList) {
        if (CollUtil.isEmpty(contractList)) {
            return Collections.emptyList();
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java
@@ -88,6 +88,10 @@
    @ExcelProperty("合同金额")
    private BigDecimal totalPrice;
    @Schema(description = "定金金额,单位:元", example = "1000.00")
    @ExcelProperty("定金金额")
    private BigDecimal depositPrice;
    @Schema(description = "已回款金额", example = "5617")
    @ExcelProperty("已回款金额")
    private BigDecimal totalReceivablePrice;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java
@@ -69,6 +69,10 @@
    @DiffLogField(name = "合同金额")
    private BigDecimal totalPrice;
    @Schema(description = "定金金额,单位:元", example = "1000.00")
    @DiffLogField(name = "定金金额")
    private BigDecimal depositPrice;
    @Schema(description = "客户签约人编号", example = "18546")
    @DiffLogField(name = "客户签约人", function = CrmContactParseFunction.NAME)
    private Long signContactId;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/quotation/CrmSaleQuotationController.java
@@ -13,6 +13,8 @@
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.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
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;
@@ -61,6 +63,8 @@
    private MdmItemApi mdmItemApi;
    @Resource
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private CrmCustomerService customerService;
    @PostMapping("/create")
    @Operation(summary = "创建销售报价单")
@@ -135,22 +139,6 @@
        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)
@@ -180,7 +168,14 @@
                MapUtils.findAndThen(unitMap, item.getItemUnitId(), unit -> item.setItemUnitName(unit.getName()));
            }));
        }
        // 2. å¡«å……关联信息
        // 2. å¡«å……客户名称
        if (saleQuotation.getCustomerId() != null) {
            CrmCustomerDO customer = customerService.getCustomer(saleQuotation.getCustomerId());
            if (customer != null) {
                respVO.setCustomerName(customer.getName());
            }
        }
        // 3. å¡«å……关联信息
        fillSaleQuotationRelatedFields(respVO);
        return respVO;
    }
@@ -195,10 +190,13 @@
        Map<Long, List<CrmSaleQuotationProductDO>> productMap = convertMultiMap(productList, CrmSaleQuotationProductDO::getQuotationId);
        // 1.2 ç‰©æ–™ä¿¡æ¯
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(convertSet(productList, CrmSaleQuotationProductDO::getItemId));
        // 1.3 ç”¨æˆ·ä¿¡æ¯
        // 1.3 å®¢æˆ·ä¿¡æ¯
        Map<Long, CrmCustomerDO> customerMap = customerService.getCustomerMap(
                convertSet(pageResult.getList(), CrmSaleQuotationDO::getCustomerId));
        // 1.4 ç”¨æˆ·ä¿¡æ¯
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(pageResult.getList(), CrmSaleQuotationDO::getOwnerUserId));
        // 1.4 éƒ¨é—¨ä¿¡æ¯
        // 1.5 éƒ¨é—¨ä¿¡æ¯
        Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(
                convertSet(pageResult.getList(), CrmSaleQuotationDO::getOwnerUserId));
@@ -207,6 +205,7 @@
            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(customerMap, vo.getCustomerId(), customer -> vo.setCustomerName(customer.getName()));
            MapUtils.findAndThen(userMap, vo.getOwnerUserId(), user -> vo.setOwnerUserName(user.getNickname()));
            MapUtils.findAndThen(deptMap, vo.getOwnerUserId(), dept -> vo.setOwnerUserDeptName(dept.getName()));
        });
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractDO.java
@@ -104,6 +104,10 @@
     */
    private BigDecimal totalPrice;
    /**
     * å®šé‡‘金额,单位:元
     */
    private BigDecimal depositPrice;
    /**
     * å…³è” ERP é”€å”®è®¢å•编号
     */
    private Long orderId;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractMapper.java
@@ -118,6 +118,10 @@
        return selectOne(CrmContractDO::getBusinessId, businessId);
    }
    default CrmContractDO selectByOrderId(Long orderId) {
        return selectOne(CrmContractDO::getOrderId, orderId);
    }
    default List<CrmContractDO> selectListByCustomerId(Long customerId) {
        return selectList(CrmContractDO::getCustomerId, customerId);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -19,6 +19,7 @@
    ErrorCode CONTRACT_GENERATE_ORDER_FAIL_NOT_APPROVE = new ErrorCode(1_020_000_006, "生成销售订单失败,原因:合同未审批通过");
    ErrorCode CONTRACT_GENERATE_ORDER_FAIL_EXISTS_ORDER = new ErrorCode(1_020_000_007, "生成销售订单失败,原因:已关联销售订单【{}】");
    ErrorCode CONTRACT_GENERATE_ORDER_FAIL_NO_PRODUCT = new ErrorCode(1_020_000_008, "生成销售订单失败,原因:合同没有产品信息");
    ErrorCode CONTRACT_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_020_000_009, "合同审批流程不存在或未激活,请检查 BPM é…ç½®");
    // ========== çº¿ç´¢ç®¡ç† 1-020-001-000 ==========
    ErrorCode CLUE_NOT_EXISTS = new ErrorCode(1_020_001_000, "线索不存在");
@@ -114,12 +115,8 @@
    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, "物料不存在或未启用");
    ErrorCode SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT = new ErrorCode(1_020_015_004, "销售报价单({})不是草稿状态,无法转合同");
    ErrorCode SALE_QUOTATION_ITEM_NOT_EXISTS = new ErrorCode(1_020_015_005, "物料不存在或未启用");
    ErrorCode SALE_QUOTATION_CONTRACT_EXISTS = new ErrorCode(1_020_015_006, "销售报价单已转合同,请勿重复操作");
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/common/CrmAuditStatusEnum.java
@@ -18,8 +18,9 @@
    DRAFT(0, "未提交"),
    PROCESS(10, "审批中"),
    APPROVE(20, "审核通过"),
    REJECT(30, "审核不通过"),
    CANCEL(40, "已取消");
    REJECT(30, "审核不通过"),
    CANCEL(40, "已取消"),
    INVALID(50, "已作废");
    private final Integer status;
    private final String name;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/quotation/CrmSaleQuotationStatusEnum.java
@@ -7,7 +7,7 @@
import java.util.Arrays;
/**
 * CRM é”€å”®æŠ¥ä»·å®¡æ‰¹çŠ¶æ€æžšä¸¾
 * CRM é”€å”®æŠ¥ä»·çŠ¶æ€æžšä¸¾
 *
 * @author èŠ‹é“æºç 
 */
@@ -16,11 +16,7 @@
public enum CrmSaleQuotationStatusEnum implements ArrayValuable<Integer> {
    DRAFT(0, "草稿"),
    PROCESS(10, "审批中"),
    APPROVE(20, "已通过"),
    REJECT(30, "已拒绝"),
    CANCEL(40, "已取消"),
    CONVERTED(50, "已转合同");
    CONVERTED(10, "已转合同");
    private final Integer status;
    private final String name;
@@ -33,4 +29,4 @@
        return ARRAYS;
    }
}
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractService.java
@@ -67,10 +67,18 @@
    /**
     * å‘起合同审批流程
     *
     * @param id     åˆåŒç¼–号
     * @param userId ç”¨æˆ·ç¼–号
     * @param id                    åˆåŒç¼–号
     * @param processDefinitionKey  æµç¨‹å®šä¹‰ Key
     * @param userId                ç”¨æˆ·ç¼–号
     */
    void submitContract(Long id, Long userId);
    void submitContract(Long id, String processDefinitionKey, Long userId);
    /**
     * èŽ·å–åˆåŒå®¡æ‰¹æµç¨‹åˆ—è¡¨
     *
     * @return æµç¨‹å®šä¹‰åˆ—表
     */
    List<Map<String, Object>> getContractApproveProcessDefinitionList();
    /**
     * æ›´æ–°åˆåŒæµç¨‹å®¡æ‰¹ç»“æžœ
@@ -211,4 +219,20 @@
     */
    Long generateSaleOrder(Long id, Long userId);
    /**
     * æ›´æ–°åˆåŒçŠ¶æ€
     *
     * @param id     åˆåŒç¼–号
     * @param status çŠ¶æ€
     */
    void updateContractStatus(Long id, Integer status);
    /**
     * æ ¹æ®é”€å”®è®¢å•编号更新合同状态
     *
     * @param orderId é”€å”®è®¢å•编号
     * @param status  çŠ¶æ€
     */
    void updateContractStatusByOrderId(Long orderId, Integer status);
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java
@@ -10,6 +10,9 @@
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
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.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractTransferReqVO;
@@ -49,10 +52,15 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.flowable.engine.repository.ProcessDefinition;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@@ -74,6 +82,11 @@
     * BPM åˆåŒå®¡æ‰¹æµç¨‹æ ‡è¯†
     */
    public static final String BPM_PROCESS_DEFINITION_KEY = "crm-contract-audit";
    /**
     * BPM åˆåŒå®¡æ‰¹åˆ†ç±»ç¼–码
     */
    private static final String CONTRACT_APPROVE_CATEGORY_CODE = "contract_approve";
    @Resource
    private CrmContractMapper contractMapper;
@@ -103,6 +116,10 @@
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MdmItemApi mdmItemApi;
@@ -123,7 +140,9 @@
        }
        // 2.1 æ’入合同
        CrmContractDO contract = BeanUtils.toBean(createReqVO, CrmContractDO.class).setNo(no);
        CrmContractDO contract = BeanUtils.toBean(createReqVO, CrmContractDO.class)
                .setNo(no)
                .setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus()); // é»˜è®¤ä¸ºæœªæäº¤çŠ¶æ€
        calculateTotalPrice(contract, contractProducts);
        contractMapper.insert(contract);
        // 2.2 æ’入合同关联商品
@@ -308,23 +327,98 @@
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_SUBMIT_SUB_TYPE, bizNo = "{{#id}}",
            success = CRM_CONTRACT_SUBMIT_SUCCESS)
    public void submitContract(Long id, Long userId) {
    public void submitContract(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒåˆåŒæ˜¯å¦åœ¨å®¡æ‰¹
        CrmContractDO contract = validateContractExists(id);
        if (ObjUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(CONTRACT_SUBMIT_FAIL_NOT_DRAFT);
        }
        // 2. åˆ›å»ºåˆåŒå®¡æ‰¹æµç¨‹å®žä¾‹
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id)));
        // 2. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        validateContractApproveCategoryAndProcessDefinition();
        // 3. æ›´æ–°åˆåŒå·¥ä½œæµç¼–号
        // 3. æ ¡éªŒä¼ å…¥çš„æµç¨‹å®šä¹‰ Key æ˜¯å¦å±žäºŽè¯¥åˆ†ç±»ä¸”处于激活状态
        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (processDefinition == null) {
            throw exception(CONTRACT_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        // 4. åˆ›å»ºåˆåŒå®¡æ‰¹æµç¨‹å®žä¾‹
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(processDefinitionKey).setBusinessKey(String.valueOf(id)));
        // 5. æ›´æ–°åˆåŒå·¥ä½œæµç¼–号
        contractMapper.updateById(new CrmContractDO().setId(id).setProcessInstanceId(processInstanceId)
                .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
        // 3. è®°å½•日志
        // 6. è®°å½•日志
        LogRecordContext.putVariable("contractName", contract.getName());
    }
    /**
     * æ ¡éªŒ BPM åˆåŒå®¡æ‰¹åˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
     */
    private void validateContractApproveCategoryAndProcessDefinition() {
        // 1. æ ¡éªŒåˆ†ç±»æ˜¯å¦å­˜åœ¨
        List<String> codes = Collections.singletonList(CONTRACT_APPROVE_CATEGORY_CODE);
        Map<String, ?> categoryMap = bpmCategoryService.getCategoryMap(codes);
        if (!categoryMap.containsKey(CONTRACT_APPROVE_CATEGORY_CODE)) {
            throw exception(CONTRACT_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
        // 2. æ ¡éªŒåˆ†ç±»ä¸‹æ˜¯å¦æœ‰å¯ç”¨çš„æµç¨‹å®šä¹‰
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(CONTRACT_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            throw exception(CONTRACT_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(CONTRACT_BPM_PROCESS_DEFINITION_NOT_EXISTS);
        }
    }
    @Override
    public List<Map<String, Object>> getContractApproveProcessDefinitionList() {
        // 1. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        validateContractApproveCategoryAndProcessDefinition();
        // 2. èŽ·å–åˆ†ç±»ä¸‹çš„æµç¨‹å®šä¹‰ä¿¡æ¯
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(CONTRACT_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
@@ -462,7 +556,8 @@
        createReqDTO.setContractNo(contract.getNo());
        createReqDTO.setSaleUserId(contract.getOwnerUserId());
        createReqDTO.setOrderTime(LocalDateTime.now().toString());
        createReqDTO.setDiscountPercent(contract.getDiscountPercent());
        createReqDTO.setDiscountPercent(contract.getDiscountPercent() != null ? contract.getDiscountPercent() : BigDecimal.ZERO);
        createReqDTO.setDepositPrice(contract.getDepositPrice() != null ? contract.getDepositPrice() : BigDecimal.ZERO);
        createReqDTO.setRemark("由合同【" + contract.getNo() + "】自动生成");
        // 5. æž„建订单项
@@ -494,4 +589,21 @@
        return orderId;
    }
    @Override
    public void updateContractStatus(Long id, Integer status) {
        contractMapper.updateById(new CrmContractDO()
                .setId(id)
                .setAuditStatus(status));
    }
    @Override
    public void updateContractStatusByOrderId(Long orderId, Integer status) {
        CrmContractDO contract = contractMapper.selectByOrderId(orderId);
        if (contract != null) {
            contractMapper.updateById(new CrmContractDO()
                    .setId(contract.getId())
                    .setAuditStatus(status));
        }
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/listener/CrmContractStatusListener.java
@@ -1,31 +1,56 @@
package cn.iocoder.yudao.module.crm.service.contract.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.mysql.contract.CrmContractMapper;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractServiceImpl;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
/**
 * åˆåŒå®¡æ‰¹çš„结果的监听器实现类
 * åˆåŒå®¡æ‰¹çš„结果监听器实现类
 *
 * é€šè¿‡ businessKey(合同ID)匹配,而不是固定的流程定义 Key,
 * è¿™æ ·å¯ä»¥æ”¯æŒåˆ†ç±»ä¸‹ä»»æ„ Key çš„æµç¨‹æ¨¡åž‹ã€‚
 *
 * @author HUIHUI
 */
@Component
public class CrmContractStatusListener extends BpmProcessInstanceStatusEventListener {
@Slf4j
public class CrmContractStatusListener implements ApplicationListener<BpmProcessInstanceStatusEvent> {
    @Resource
    private CrmContractService contractService;
    @Resource
    private CrmContractMapper contractMapper;
    @Override
    public String getProcessDefinitionKey() {
        return CrmContractServiceImpl.BPM_PROCESS_DEFINITION_KEY;
    }
    public void onApplicationEvent(BpmProcessInstanceStatusEvent event) {
        // é€šè¿‡ businessKey æŸ¥è¯¢åˆåŒ
        Long contractId;
        try {
            contractId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException e) {
            return; // businessKey ä¸æ˜¯æ•°å­—,忽略
        }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        contractService.updateContractAuditStatus(Long.parseLong(event.getBusinessKey()), event.getStatus());
        CrmContractDO contract = contractMapper.selectById(contractId);
        if (contract == null) {
            return; // ä¸æ˜¯åˆåŒï¼Œå¿½ç•¥
        }
        // åªæœ‰å®¡æ‰¹ä¸­çŠ¶æ€çš„åˆåŒæ‰å¤„ç†
        if (!CrmAuditStatusEnum.PROCESS.getStatus().equals(contract.getAuditStatus())) {
            log.warn("[onApplicationEvent] åˆåŒ({}) ä¸å¤„于审批中状态,当前状态: {}", contractId, contract.getAuditStatus());
            return;
        }
        log.info("[onApplicationEvent] åˆåŒ({}) å®¡æ‰¹çŠ¶æ€æ›´æ–°: {}", contractId, event.getStatus());
        // æ›´æ–°å®¡æ‰¹çŠ¶æ€
        contractService.updateContractAuditStatus(contractId, event.getStatus());
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationService.java
@@ -8,7 +8,6 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
 * CRM é”€å”®æŠ¥ä»·å• Service æŽ¥å£
@@ -88,30 +87,6 @@
     * @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();
    /**
     * è½¬ä¸ºåˆåŒ
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
@@ -1,16 +1,9 @@
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;
@@ -33,7 +26,6 @@
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;
@@ -54,11 +46,6 @@
@Validated
@Slf4j
public class CrmSaleQuotationServiceImpl implements CrmSaleQuotationService {
    /**
     * é”€å”®æŠ¥ä»· BPM åˆ†ç±»ç¼–码
     */
    private static final String SALE_QUOTATION_APPROVE_CATEGORY_CODE = "sale_quotation_approve";
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
@@ -81,13 +68,6 @@
    private CrmPermissionService permissionService;
    @Resource
    private CrmContractService contractService;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -186,7 +166,8 @@
                throw exception(SALE_QUOTATION_ITEM_NOT_EXISTS);
            }
            item.setItemUnitId(mdmItem.getUnitMeasureId());
            item.setItemPrice(mdmItem.getSalesPrice());
            // ç‰©æ–™åŽŸä»·ï¼Œå¦‚æžœ MDM ä¸­æ²¡æœ‰åˆ™é»˜è®¤ä¸º 0
            item.setItemPrice(mdmItem.getSalesPrice() != null ? mdmItem.getSalesPrice() : BigDecimal.ZERO);
            if (item.getQuotationPrice() != null && item.getCount() != null) {
                item.setTotalPrice(MoneyUtils.priceMultiply(item.getQuotationPrice(), item.getCount()));
                if (item.getTaxPercent() != null) {
@@ -276,138 +257,11 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitSaleQuotation(Long id, String processDefinitionKey, Long userId) {
    public Long convertToContract(Long id, 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);
            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo());
        }
        // æ ¡éªŒæ˜¯å¦å·²è½¬åˆåŒ
        if (saleQuotation.getContractId() != null) {
@@ -437,7 +291,7 @@
        });
        contractVO.setProducts(contractProducts);
        // 4. åˆ›å»ºåˆåŒ
        // 4. åˆ›å»ºåˆåŒï¼ˆä¼šè‡ªåŠ¨æ£€æµ‹åˆåŒå®¡æ‰¹æµç¨‹å¹¶æäº¤å®¡æ‰¹ï¼‰
        Long contractId = contractService.createContract(contractVO, userId);
        // 5. æ›´æ–°æŠ¥ä»·å•关联信息
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/listener/CrmSaleQuotationStatusListener.java
ÎļþÒÑɾ³ý
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/ErpSaleOrderController.java
@@ -83,6 +83,24 @@
        return success(true);
    }
    @PutMapping("/cancel")
    @Operation(summary = "作废销售订单")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('erp:sale-order:cancel')")
    public CommonResult<Boolean> cancelSaleOrder(@RequestParam("id") Long id) {
        saleOrderService.cancelSaleOrder(id);
        return success(true);
    }
    @PutMapping("/enable")
    @Operation(summary = "重新启用销售订单")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('erp:sale-order:enable')")
    public CommonResult<Boolean> enableSaleOrder(@RequestParam("id") Long id) {
        saleOrderService.enableSaleOrder(id);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除销售订单")
    @Parameter(name = "ids", description = "编号数组", required = true)
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/sale/vo/order/ErpSaleOrderRespVO.java
@@ -33,6 +33,12 @@
    @ExcelProperty("客户名称")
    private String customerName;
    @Schema(description = "关联合同编号", example = "1")
    private Long contractId;
    @Schema(description = "合同单号", example = "HT001")
    @ExcelProperty("合同单号")
    private String contractNo;
    @Schema(description = "结算账户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "311.89")
    @ExcelProperty("结算账户编号")
    private Long accountId;
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErpAuditStatus.java
@@ -18,7 +18,8 @@
public enum ErpAuditStatus implements ArrayValuable<Integer> {
    PROCESS(10, "未审核"), // å®¡æ ¸ä¸­
    APPROVE(20, "已审核"); // å®¡æ ¸é€šè¿‡
    APPROVE(20, "已审核"), // å®¡æ ¸é€šè¿‡
    CANCEL(30, "已作废"); // å·²ä½œåºŸ/已撤销
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(ErpAuditStatus::getStatus).toArray(Integer[]::new);
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
@@ -78,6 +78,12 @@
    ErrorCode SALE_ORDER_PROCESS_FAIL_EXISTS_OUT = new ErrorCode(1_020_201_008, "反审核失败,已存在对应的销售出库单");
    ErrorCode SALE_ORDER_ITEM_RETURN_FAIL_OUT_EXCEED = new ErrorCode(1_020_201_009, "销售订单项({})超过最大允许退货数量({})");
    ErrorCode SALE_ORDER_PROCESS_FAIL_EXISTS_RETURN = new ErrorCode(1_020_201_010, "反审核失败,已存在对应的销售退货单");
    ErrorCode SALE_ORDER_CANCEL_FAIL_NOT_APPROVE = new ErrorCode(1_020_201_011, "作废失败,销售订单({})未审核,只有已审核的订单才能作废");
    ErrorCode SALE_ORDER_CANCEL_FAIL_EXISTS_OUT = new ErrorCode(1_020_201_012, "作废失败,已存在对应的销售出库单");
    ErrorCode SALE_ORDER_CANCEL_FAIL_EXISTS_RETURN = new ErrorCode(1_020_201_013, "作废失败,已存在对应的销售退货单");
    ErrorCode SALE_ORDER_CANCEL_FAIL = new ErrorCode(1_020_201_014, "作废失败,请刷新页面后重试");
    ErrorCode SALE_ORDER_ENABLE_FAIL_NOT_CANCEL = new ErrorCode(1_020_201_015, "启用失败,销售订单({})未作废,只有已作废的订单才能重新启用");
    ErrorCode SALE_ORDER_ENABLE_FAIL = new ErrorCode(1_020_201_016, "启用失败,请刷新页面后重试");
    // ========== ERP é”€å”®å‡ºåº“(1-030-202-000) ==========
    ErrorCode SALE_OUT_NOT_EXISTS = new ErrorCode(1_020_202_000, "销售出库单不存在");
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderService.java
@@ -43,6 +43,20 @@
    void updateSaleOrderStatus(Long id, Integer status);
    /**
     * ä½œåºŸé”€å”®è®¢å•
     *
     * @param id ç¼–号
     */
    void cancelSaleOrder(Long id);
    /**
     * é‡æ–°å¯ç”¨é”€å”®è®¢å•
     *
     * @param id ç¼–号
     */
    void enableSaleOrder(Long id);
    /**
     * æ›´æ–°é”€å”®è®¢å•的出库数量
     *
     * @param id ç¼–号
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.contract.CrmContractApi;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderSaveReqVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductDO;
@@ -56,6 +57,8 @@
    private ErpProductService productService;
    @Resource
    private CrmCustomerApi customerApi;
    @Resource
    private CrmContractApi contractApi;
    @Resource
    private ErpAccountService accountService;
@@ -147,11 +150,13 @@
            throw exception(approve ? SALE_ORDER_APPROVE_FAIL : SALE_ORDER_PROCESS_FAIL);
        }
        // 1.3 å­˜åœ¨é”€å”®å‡ºåº“单,无法反审核
        if (!approve && saleOrder.getOutCount().compareTo(BigDecimal.ZERO) > 0) {
        BigDecimal outCount = saleOrder.getOutCount() != null ? saleOrder.getOutCount() : BigDecimal.ZERO;
        if (!approve && outCount.compareTo(BigDecimal.ZERO) > 0) {
            throw exception(SALE_ORDER_PROCESS_FAIL_EXISTS_OUT);
        }
        // 1.4 å­˜åœ¨é”€å”®é€€è´§å•,无法反审核
        if (!approve && saleOrder.getReturnCount().compareTo(BigDecimal.ZERO) > 0) {
        BigDecimal returnCount = saleOrder.getReturnCount() != null ? saleOrder.getReturnCount() : BigDecimal.ZERO;
        if (!approve && returnCount.compareTo(BigDecimal.ZERO) > 0) {
            throw exception(SALE_ORDER_PROCESS_FAIL_EXISTS_RETURN);
        }
@@ -163,6 +168,62 @@
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelSaleOrder(Long id) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        ErpSaleOrderDO saleOrder = validateSaleOrderExists(id);
        // 1.2 æ ¡éªŒçŠ¶æ€ï¼šåªæœ‰å·²å®¡æ ¸çš„è®¢å•æ‰èƒ½ä½œåºŸ
        if (!ErpAuditStatus.APPROVE.getStatus().equals(saleOrder.getStatus())) {
            throw exception(SALE_ORDER_CANCEL_FAIL_NOT_APPROVE, saleOrder.getNo());
        }
        // 1.3 å­˜åœ¨é”€å”®å‡ºåº“单,无法作废
        BigDecimal outCount = saleOrder.getOutCount() != null ? saleOrder.getOutCount() : BigDecimal.ZERO;
        if (outCount.compareTo(BigDecimal.ZERO) > 0) {
            throw exception(SALE_ORDER_CANCEL_FAIL_EXISTS_OUT);
        }
        // 1.4 å­˜åœ¨é”€å”®é€€è´§å•,无法作废
        BigDecimal returnCount = saleOrder.getReturnCount() != null ? saleOrder.getReturnCount() : BigDecimal.ZERO;
        if (returnCount.compareTo(BigDecimal.ZERO) > 0) {
            throw exception(SALE_ORDER_CANCEL_FAIL_EXISTS_RETURN);
        }
        // 2. æ›´æ–°çŠ¶æ€ä¸ºå·²ä½œåºŸ
        int updateCount = saleOrderMapper.updateByIdAndStatus(id, saleOrder.getStatus(),
                new ErpSaleOrderDO().setStatus(ErpAuditStatus.CANCEL.getStatus()));
        if (updateCount == 0) {
            throw exception(SALE_ORDER_CANCEL_FAIL);
        }
        // 3. åŒæ­¥æ›´æ–°å…³è”合同状态为已作废
        if (saleOrder.getContractId() != null) {
            contractApi.updateContractStatus(saleOrder.getContractId(), 50).getCheckedData(); // 50 = å·²ä½œåºŸ
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void enableSaleOrder(Long id) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        ErpSaleOrderDO saleOrder = validateSaleOrderExists(id);
        // 1.2 æ ¡éªŒçŠ¶æ€ï¼šåªæœ‰å·²ä½œåºŸçš„è®¢å•æ‰èƒ½é‡æ–°å¯ç”¨
        if (!ErpAuditStatus.CANCEL.getStatus().equals(saleOrder.getStatus())) {
            throw exception(SALE_ORDER_ENABLE_FAIL_NOT_CANCEL, saleOrder.getNo());
        }
        // 2. æ›´æ–°çŠ¶æ€ä¸ºå·²å®¡æ ¸
        int updateCount = saleOrderMapper.updateByIdAndStatus(id, saleOrder.getStatus(),
                new ErpSaleOrderDO().setStatus(ErpAuditStatus.APPROVE.getStatus()));
        if (updateCount == 0) {
            throw exception(SALE_ORDER_ENABLE_FAIL);
        }
        // 3. åŒæ­¥æ›´æ–°å…³è”合同状态为审核通过
        if (saleOrder.getContractId() != null) {
            contractApi.updateContractStatus(saleOrder.getContractId(), 20).getCheckedData(); // 20 = å®¡æ ¸é€šè¿‡
        }
    }
    private List<ErpSaleOrderItemDO> validateSaleOrderItems(List<ErpSaleOrderSaveReqVO.Item> list) {
        // 1. æ ¡éªŒäº§å“å­˜åœ¨
        List<ErpProductDO> productList = productService.validProductList(