gaoluyang
7 小时以前 2a26a5fa027d17aa11bde0b7fdc4e80bde554c84
银川九鸿化工需求:商机状态组改为非必填

- 新增商机时「商机状态组」不再必填;未选状态组则不写默认阶段,也不参与状态组维度统计
- 变更商机阶段时先校验状态组:未设置状态组的商机给出精确提示(新增错误码 1_020_002_005,含商机名称与下一步动作),只允许改「结束状态」
- crm_business.status_type_id / status_id 放宽为可空(配置导出脚本 config_export_all_20260904.sql 同步)
- 业务可视化文档同步(销售流程/商机对象:状态组选填)

过程检验单的生产订单/工位在本分支不具备(IPQC 以产品物料为准),无需改动。

Co-Authored-By: Claude Code <noreply@anthropic.com>
已修改10个文件
42 ■■■■■ 文件已修改
docs/project-business/data/flows.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/02-sales-flow.mmd 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20260904.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessRespVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json
@@ -3,7 +3,7 @@
    {"id": "sales", "name": "销售流程", "mermaid": "mermaid/02-sales-flow.mmd", "color": "#4f9cff",
     "summary": "线索转化客户 → 商机推进 → 报价(可AI识别) → 合同 BPM 审批 → 生成 ERP 销售订单并审批 → 需生产行自动建 MPS → 发货通知 → 销售出库(OQC)回写 → 开票(BPM) → 回款(先开票校验)。",
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机(商机状态组/阶段选填,不选也能建单)", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR 预填),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同提交 BPM 审批(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction 明细创建 MPS", "roles": ["销售", "计划员"]},
docs/project-business/data/modules.json
@@ -11,7 +11,7 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段(状态组选填)", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
docs/project-business/data/objects.json
@@ -5,7 +5,7 @@
    {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"},
    {"name": "线索", "module": "销售管理", "table": "crm_clue", "doc": "原始销售线索,仅一次转化为客户", "upstream": [], "downstream": ["客户"], "keywords": "线索 clue"},
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进(商机状态组/阶段为选填:不选状态组也能建单,此时无默认阶段、不可变更阶段、不参与状态组统计),结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段 状态组"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM 物料报价,AI OCR 录入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号(新增/编辑均可自定义,留空则新增自动生成、编辑保持原编号),BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号,BPM 审批,累计开票≤合同额,附件必传", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 发票 invoice"},
docs/project-business/index.html
@@ -341,7 +341,7 @@
    {"key": "crm", "name": "销售管理(客户关系)", "color": "#4f9cff", "tag": "CRM", "flow": "sales",
     "positioning": "销售前端:从线索到回款的客户经营",
     "problem": "客户资产沉淀、商机转化、合同审批与开票回款管控",
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "features": ["线索转化客户", "公海池与领取上限", "联系人", "商机自定义阶段(状态组选填)", "跟进记录", "报价单(AI OCR)", "合同(BPM审批→生成销售订单)", "开票(BPM)→回款(先开票校验)", "回款计划", "销售目标", "数据权限三级(负责人/只读/读写)"],
     "objects": ["线索", "客户", "联系人", "商机", "报价单", "合同", "发票", "回款", "销售目标"],
     "roles": ["销售内勤", "销售经理", "财务(开票回款)"],
     "upstream": ["主数据(物料)"], "downstream": ["进销存(销售订单)", "售后(客户)", "AI(报价OCR)"],
@@ -440,7 +440,7 @@
    {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"},
    {"name": "线索", "module": "销售管理", "table": "crm_clue", "doc": "原始销售线索,仅一次转化为客户", "upstream": [], "downstream": ["客户"], "keywords": "线索 clue"},
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进(商机状态组/阶段为选填:不选状态组也能建单,此时无默认阶段、不可变更阶段、不参与状态组统计),结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段 状态组"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM 物料报价,AI OCR 录入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号(新增/编辑均可自定义,留空则新增自动生成、编辑保持原编号),BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号,BPM 审批,累计开票≤合同额,附件必传", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 发票 invoice"},
@@ -490,7 +490,7 @@
    {"id": "sales", "name": "销售流程", "mermaid": "mermaid/02-sales-flow.mmd", "color": "#4f9cff",
     "summary": "线索转化客户 → 商机推进 → 报价(可AI识别) → 合同 BPM 审批 → 生成 ERP 销售订单并审批 → 需生产行自动建 MPS → 发货通知 → 销售出库(OQC)回写 → 开票(BPM) → 回款(先开票校验)。",
     "steps": [
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机", "roles": ["销售内勤"]},
       {"stage": "获客", "action": "线索转化客户、建联系人、立项商机(商机状态组/阶段选填,不选也能建单)", "roles": ["销售内勤"]},
       {"stage": "报价", "action": "商机赢单后建报价单(AI OCR 预填),转合同", "roles": ["销售内勤"]},
       {"stage": "签约", "action": "合同提交 BPM 审批(可选流程定义),通过=20", "roles": ["销售", "审批人"]},
       {"stage": "接单", "action": "合同一键生成销售订单(XSDD),销售订单再走 BPM;审批通过自动为 needProduction 明细创建 MPS", "roles": ["销售", "计划员"]},
docs/project-business/mermaid/02-sales-flow.mmd
@@ -2,7 +2,7 @@
  A[线索 crm_clue] -->|转化·仅一次| B[客户 crm_customer]
  B --> C[联系人 crm_contact]
  B --> D[商机 crm_business]
  D -->|自定义阶段推进| E{商机结束}
  D -->|自定义阶段推进(状态组选填)| E{商机结束}
  E -->|赢单| F[报价单 QT·草稿0→已转合同10]
  E -->|输单或无效| X[结束]
  F -->|转合同·带入客户产品折扣| G[合同 HT·草稿0→审批中10]
docs/sql/config_export_all_20260904.sql
@@ -4060,8 +4060,8 @@
  `contact_last_time` datetime DEFAULT NULL COMMENT '最后跟进时间',
  `contact_next_time` datetime DEFAULT NULL COMMENT '下次联系时间',
  `owner_user_id` bigint DEFAULT NULL COMMENT '负责人的用户编号',
  `status_type_id` bigint NOT NULL COMMENT '商机状态组编号',
  `status_id` bigint NOT NULL COMMENT '商机状态编号',
  `status_type_id` bigint DEFAULT NULL COMMENT '商机状态组编号',
  `status_id` bigint DEFAULT NULL COMMENT '商机状态编号',
  `end_status` int DEFAULT NULL COMMENT '结束状态',
  `end_remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '结束时的备注',
  `deal_time` datetime DEFAULT NULL COMMENT '预计成交日期',
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessRespVO.java
@@ -52,15 +52,15 @@
    @ExcelProperty("负责人部门")
    private String ownerUserDeptName;
    @Schema(description = "商机状态组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25714")
    @Schema(description = "商机状态组编号(未设置状态组时为空)", example = "25714")
    private Long statusTypeId;
    @Schema(description = "商机状组名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "进行中")
    @Schema(description = "商机状组名字", example = "进行中")
    @ExcelProperty("商机状态组")
    private String statusTypeName;
    @Schema(description = "商机状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "30320")
    @Schema(description = "商机状态编号(未设置状态组时为空)", example = "30320")
    private Long statusId;
    @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "跟进中")
    @Schema(description = "状态名称", example = "跟进中")
    @ExcelProperty("商机状态")
    private String statusName;
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java
@@ -43,9 +43,8 @@
    @DiffLogField(name = "负责人", function = SysAdminUserParseFunction.NAME)
    private Long ownerUserId;
    @Schema(description = "商机状态组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25714")
    @Schema(description = "商机状态组编号(非必填:未选择时不带默认阶段,也不参与状态组统计)", example = "25714")
    @DiffLogField(name = "商机状态组")
    @NotNull(message = "商机状态组不能为空")
    private Long statusTypeId;
    @Schema(description = "预计成交日期")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -32,6 +32,7 @@
    ErrorCode BUSINESS_UPDATE_STATUS_FAIL_END_STATUS = new ErrorCode(1_020_002_002, "更新商机状态失败,原因:已经是结束状态");
    ErrorCode BUSINESS_UPDATE_STATUS_FAIL_STATUS_EQUALS = new ErrorCode(1_020_002_003, "更新商机状态失败,原因:已经是该状态");
    ErrorCode BUSINESS_CONTACT_CUSTOMER_NOT_MATCH = new ErrorCode(1_020_002_004, "商机关联的联系人不属于该客户");
    ErrorCode BUSINESS_UPDATE_STATUS_FAIL_NO_STATUS_TYPE = new ErrorCode(1_020_002_005, "商机【{}】未设置「商机状态组」,无法变更「商机阶段」。请在新增商机时选择「商机状态组」(需先在【营销管理 - 系统配置 - 商机状态配置】中创建状态组),或改为只变更「结束状态」");
    // ========== 联系人管理 1-020-003-000 ==========
    ErrorCode CONTACT_NOT_EXISTS = new ErrorCode(1_020_003_000, "联系人不存在");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java
@@ -100,7 +100,11 @@
        // 2.1 插入商机
        CrmBusinessDO business = BeanUtils.toBean(createReqVO, CrmBusinessDO.class);
        business.setStatusId(businessStatusService.getBusinessStatusListByTypeId(createReqVO.getStatusTypeId()).get(0).getId()); // 默认状态
        // 商机状态组为选填:未选择时不设置默认阶段,也不参与「商机状态组」维度的统计
        if (createReqVO.getStatusTypeId() != null) {
            List<CrmBusinessStatusDO> statusList = businessStatusService.getBusinessStatusListByTypeId(createReqVO.getStatusTypeId());
            business.setStatusId(CollUtil.isNotEmpty(statusList) ? statusList.get(0).getId() : null); // 默认状态
        }
        calculateTotalPrice(business, businessProducts);
        businessMapper.insert(business);
        // 2.2 插入商机关联商品
@@ -297,6 +301,10 @@
        // 1.3 校验商机状态
        CrmBusinessStatusDO status = null;
        if (reqVO.getStatusId() != null) {
            // 商机状态组为选填:未设置状态组的商机没有可选阶段,不允许变更「商机阶段」
            if (business.getStatusTypeId() == null) {
                throw exception(BUSINESS_UPDATE_STATUS_FAIL_NO_STATUS_TYPE, business.getName());
            }
            status = businessStatusService.validateBusinessStatus(business.getStatusTypeId(), reqVO.getStatusId());
        }
        // 1.4 校验是不是状态没变更
@@ -312,7 +320,7 @@
        // 3. 记录操作日志上下文
        LogRecordContext.putVariable("businessName", business.getName());
        LogRecordContext.putVariable("oldStatusName", getBusinessStatusName(business.getEndStatus(),
                businessStatusService.getBusinessStatus(business.getStatusId())));
                business.getStatusId() != null ? businessStatusService.getBusinessStatus(business.getStatusId()) : null));
        LogRecordContext.putVariable("newStatusName", getBusinessStatusName(reqVO.getEndStatus(), status));
    }