Browse Source

商机新建接口补齐字段(opp-create-fields 票 01-05)

- 实体加 address + 商机关键联系人快照三列;CreateOpportunityRequest 补 7 字段(项目金额/地址/客户角色/是否主要/关键联系人 id+姓名+公司),remark 展示名改「项目概况」、cityCode 注释放宽(市码或区/县码均可)
- OpportunityIntakeSpec 扩 7 组件;OpportunityIntakeImpl 建档透传落主表,linkPrimaryCustomer 按 isPrimaryIntended 分支(=1 刷主客户冗余并回写 / =0 留空不回写,无空转 UPDATE),customerRole/isPrimaryIntended 空回退 ROLE_INTENDED/1 保持线索转入路径兼容
- OpportunityCreateServiceImpl 翻译 7 新字段进 spec,补 customerId 非空时校验(isPrimaryIntended 必填且仅 1/0、customerRole 必填;customerId 空则忽略);OpportunityCreationPortImpl 显式传 null,LEAD_CONVERT 行为不变(线索转入映射另票)
- 测试:恢复并按新契约更新 OpportunityIntakeImplTest(分支/透传断言),CreateServiceImplTest 补 4 校验用例,OpportunityTest 字段契约同步 4 新列;crm-opportunity 233 测试全绿
- 文档:CONTEXT.md 放宽「关联客户」不变式(创建允许零主要意向客户)+ 新增项目概况/项目详细地址/商机关键联系人词条;api-docs 仓 新建商机.bru 参数表/示例同步
master
luoweijian 5 days ago
parent
commit
acd0d944c7
  1. 46
      .scratch/opportunity-create-fields/issues/01-entity-dto-fields.md
  2. 53
      .scratch/opportunity-create-fields/issues/02-intake-spec.md
  3. 37
      .scratch/opportunity-create-fields/issues/03-create-adapter.md
  4. 34
      .scratch/opportunity-create-fields/issues/04-context-doc.md
  5. 39
      .scratch/opportunity-create-fields/issues/05-bruno-doc.md
  6. 42
      .scratch/opportunity-create-fields/spec.md
  7. 14
      crm-opportunity/CONTEXT.md
  8. 27
      crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java
  9. 18
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java
  10. 36
      crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java
  11. 36
      crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java
  12. 5
      crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java
  13. 22
      crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java
  14. 19
      crm-opportunity/src/test/java/com/crm/opportunity/domain/entity/OpportunityTest.java
  15. 92
      crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java
  16. 80
      crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java

46
.scratch/opportunity-create-fields/issues/01-entity-dto-fields.md

@ -0,0 +1,46 @@
# 01 实体 + DTO 字段扩展
Type: task
Status: resolved
## 目标
`crm-opportunity` 的商机主表实体与创建请求 DTO 补齐 spec.md 拍板的字段。
## 改动
### 1. `Opportunity` 实体(`crm-opportunity/.../domain/entity/Opportunity.java`)
新增 4 列,照现有 `@Comment` + `@Column(columnDefinition=...)` 风格:
- `address`:varchar(255) comment '项目详细地址'(形状对齐 `Lead.address`
- `keyContactId`:bigint comment '商机关键联系人ID(引用 A4 联系人,未建可空)'
- `keyContactNameSnapshot`:varchar(200) comment '商机关键联系人姓名快照'
- `keyContactCompanySnapshot`:varchar(200) comment '关键人所在公司快照'
`projectAmount` 已存在,不动。
### 2. `CreateOpportunityRequest` DTO(`crm-opportunity/.../domain/dto/CreateOpportunityRequest.java`)
新增字段:
- `projectAmount`(BigDecimal,项目金额)
- `address`(String,项目详细地址)
- `customerRole`(String,客户类型 → customer_role 字典 code)
- `isPrimaryIntended`(Integer,是否主要客户:1=主要 / 0=普通关联)
- `keyContactId`(Long,可空)
- `keyContactName`(String,快照)
- `keyContactCompany`(String,快照)
`remark` 字段的 javadoc 展示名从「备注」改为「项目概况」,补一句「线索转商机时应映射来源线索对应字段」。
## 校验注意
- 本票只加字段与注释,不做业务校验(校验在 03 票)。
- 加列后跑 BOM 扫描 + `mvn -pl crm-opportunity compile` 验证无 `\uFEFF`
## 完成标准
- [ ] 实体 4 新列 + DTO 7 新字段 + remark 改名注释
- [ ] `mvn -pl crm-opportunity compile` 通过
- [ ] 全仓 `*.java` BOM 扫描干净

53
.scratch/opportunity-create-fields/issues/02-intake-spec.md

@ -0,0 +1,53 @@
# 02 商机建档内部契约 + 深模块透传
Type: task
Status: resolved
## 目标
把 01 票新增的字段贯通 `OpportunityIntakeSpec``OpportunityIntakeImpl`,让两条入口共享的深模块能落这些字段。
## 改动
### 1. `OpportunityIntakeSpec`(`crm-opportunity/.../intake/OpportunityIntakeSpec.java`)
record 新增组件:
- `BigDecimal projectAmount`
- `String address`
- `String customerRole`
- `Integer isPrimaryIntended`
- `Long keyContactId`
- `String keyContactName`
- `String keyContactCompany`
同步更新 javadoc 的 `@param` 列表。
### 2. `OpportunityIntakeImpl.buildOpportunity`
把新字段 set 进 `Opportunity`
- `projectAmount` / `address` / `keyContactId` / `keyContactNameSnapshot` / `keyContactCompanySnapshot` 直接 set
- `customerRole` / `isPrimaryIntended` 不落主表(它们是 `opportunity_customer` 子表字段)
### 3. `OpportunityIntakeImpl.linkPrimaryCustomer`
现在硬编码 `ROLE_INTENDED` + `isPrimaryIntended=1`,改为按 spec 取值:
- `customerRole`:spec 非空则用,空则回退 `ROLE_INTENDED`(保持线索转入路径兼容)
- `isPrimaryIntended`:spec 非空则用(1 或 0),空则回退 1
**主表冗余刷写按是否主要分支**:
- `isPrimaryIntended == 1` → 刷 `primaryCustomerId` / `primaryCustomerNameSnapshot`
- `isPrimaryIntended == 0` → **不刷**主表主要客户冗余(留空,普通关联不占主客户锚点)
### 4. 调用点同步
`OpportunityCreateServiceImpl``OpportunityCreationPortImpl` 两个 adapter 构造 spec 的位置都要跟上 record 新组件(本票只保证编译通过 + 传 null 或默认值;业务接线在 03 票)。
## 完成标准
- [ ] spec record 扩展 + 两 adapter 编译通过
- [ ] `linkPrimaryCustomer``isPrimaryIntended` 分支刷主表冗余
- [ ] `mvn -pl crm-opportunity compile` 通过

37
.scratch/opportunity-create-fields/issues/03-create-adapter.md

@ -0,0 +1,37 @@
# 03 直接新建 adapter 业务接线 + 校验
Type: task
Status: resolved
## 目标
把 01 票新增的 DTO 字段在 `OpportunityCreateServiceImpl.createOpportunity` 里真实翻译进 `OpportunityIntakeSpec`,并补业务校验。
## 改动
### 1. DTO → spec 翻译(`OpportunityCreateServiceImpl`)
`new OpportunityIntakeSpec(...)` 构造处,把以下字段从 request 传入(替换相应位置的 null):
- `projectAmount``request.getProjectAmount()`
- `address``request.getAddress()`
- `customerRole``request.getCustomerRole()`
- `isPrimaryIntended``request.getIsPrimaryIntended()`
- `keyContactId` / `keyContactName` / `keyContactCompany` ← request 对应字段
### 2. 校验规则
- `isPrimaryIntended`:当 `customerId` 非空时必填,取值仅 1/0;`customerId` 为空时忽略(不强制,A4 未建)。
- `customerRole`:当 `customerId` 非空时必填;空则沿用深模块回退 `ROLE_INTENDED`
- 六必填校验(D-14)不变。
### 3. `OpportunityCreationPortImpl`(线索转入 adapter)
本票**不改业务语义**:新 spec 组件传 null / 默认(保持 LEAD_CONVERT 现有行为),只保证编译通过。
## 完成标准
- [ ] createOpportunity 翻译 7 个新字段进 spec
- [ ] isPrimaryIntended / customerRole 校验按规则
- [ ] 线索转入 port 编译通过、行为不变
- [ ] `mvn -pl crm-opportunity compile` 通过

34
.scratch/opportunity-create-fields/issues/04-context-doc.md

@ -0,0 +1,34 @@
# 04 领域文档同步(CONTEXT.md)
Type: task
Status: resolved
## 目标
把 spec.md 的 7 条决策中「改不变式 / 新增词汇」的部分落进 `crm-opportunity/CONTEXT.md`,保持领域语言与代码一致。
## 改动
### 1. 「关联客户」词条 —— 放宽不变式
现状:「两条入口都必填主要意向客户…保证主客户约束自创建起恒成立」。
改为:
- 创建时**至少挂一个关联客户**(直接新建表单 `customerId`/`customerName` 非空),但**是否主要**由 `isPrimaryIntended` 决定;
- `isPrimaryIntended=0`(普通关联)时创建完成**允许零主要意向客户**,主表主要客户冗余留空;
- 主要意向客户允许后补/切换(走「添加关联客户」/「改主要意向客户」端点);
- 「有且仅一个主要意向客户」的约束保留在**存在主要客户时**,不再要求「自创建起恒成立」。
### 2. 新增词条
- **商机关键联系人**:商机下的关键联系人快照(`keyContactId` 引用 A4 联系人 + 姓名/公司快照,A4 未建可空)。与「客户联系人」区分——客户联系人归 A4 客户域。
- **项目详细地址**:商机主表字段 `address`(选填),项目地理位置的文本补充(省/市/区之外的门牌地址)。对称 lead 的 `address`
### 3. 「备注」与「项目概况」等价说明
`remark` 相关处注明:接口展示名「项目概况」,字段/列名仍 `remark`(同字段改名,非新列)。
## 完成标准
- [ ] CONTEXT.md 三处更新,无矛盾残留

39
.scratch/opportunity-create-fields/issues/05-bruno-doc.md

@ -0,0 +1,39 @@
# 05 接口文档同步(Bruno .bru)
Type: task
Status: resolved
## 目标
代码(01–03 票)改完后,同步接口文档仓的 `新建商机.bru`,与 `CreateOpportunityRequest` 保持一致。
## 文件
`D:\code\crm-api-docs\A3 商机管理\新建商机\新建商机.bru`
## 改动
### 1. 请求参数表新增 7 行
| 参数 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| projectAmount | string | 否 | - | 项目金额(BigDecimal,序列化为 string) |
| address | string | 否 | - | 项目详细地址 |
| customerRole | string | 否 | - | 客户类型 → 关联客户角色字典 code(customerId 非空时必填) |
| isPrimaryIntended | number | 否 | 1 | 是否主要客户:1是 0否(customerId 非空时必填;0=普通关联,不刷主客户冗余) |
| keyContactId | string | 否 | - | 商机关键联系人 ID(引用 A4 联系人,未建可空) |
| keyContactName | string | 否 | - | 商机关键联系人姓名快照 |
| keyContactCompany | string | 否 | - | 关键人所在公司快照 |
### 2. 既有行修正
- `cityCode` 说明补:「市/区国标 code(前端三级级联选到区/县时传区/县 code)」
- `remark` 说明从「备注」改为「项目概况(备注,线索转商机时映射)」
### 3. 请求示例补字段
`body:form-urlencoded``docs` 内的 curl 示例追加新字段(示例值自拟,标注非真实返回)。
## 完成标准
- [ ] .bru 参数表 / 示例 / body 与最终 DTO 一致

42
.scratch/opportunity-create-fields/spec.md

@ -0,0 +1,42 @@
# 商机新建接口补齐字段(opp-create-fields)
## 背景
对比原型 A3-1-1-2-1「新增/编辑商机」表单与商机侧直接新建接口 `POST /api/opportunity`
(`CreateOpportunityRequest` / `OpportunityIntakeSpec` / `OpportunityIntakeImpl`),确认接口与接口文档
漏了 7 类字段。经 grilling 逐条拍板如下。
## 决策(grilling 已确认,2026-09)
| # | 原型字段 | 结论 |
|---|---------|------|
| 1 | 项目区域(省/市/区三级级联) | 前端三级级联,区/县折叠进 `cityCode`,**不新增** `districtCode`(对称 lead 约定「市码或区/县码均可,不单建 district_code」)。仅放宽 `cityCode` 的 Schema/注释说明。 |
| 2 | 项目金额 | 补进 create 入参。`Opportunity.projectAmount` 已存在,DTO/文档漏带。 |
| 3 | 项目详细地址 | 补进 create 入参 + 实体加 `address`(照 `Lead.address` 形状 varchar(255))。 |
| 4 | 客户类型 | 映射到 `customer_role`(商机关联客户角色字典)。不碰 A4 的 `customer_type`。 |
| 5 | 是否主要客户 | create 收 `isPrimaryIntended`(1=主要意向客户 / 0=普通关联客户)。**创建时允许零主要意向客户**(否→普通关联,后补/切换走添加关联客户端点)。CONTEXT「关联客户」不变式相应放宽。 |
| 6 | 商机关键联系人 + 关键人所在公司 | create **预留快照字段**:`keyContactId`(可空,引用 A4 联系人)+ `keyContactNameSnapshot` + `keyContactCompanySnapshot`。**客户联系人**归 A4 延后,stub 兜底。 |
| 7 | 项目概况 | = 后端 `remark`(同字段改名,**不新增列**)。DTO/文档展示名「备注」→「项目概况」,补「线索转商机时映射」注释。 |
## 不算缺口
- 项目阶段:create 由系统落首节点(`applyInitialStage`),非入参。
- 来源线索:血缘 `sourceLeadId` 已存在。
- 甲方(`partyAClear` / `partyA`):已存在。
## 影响面
- 代码:`crm-opportunity`
- `CreateOpportunityRequest`(DTO)
- `Opportunity`(实体,加 4 列)
- `OpportunityIntakeSpec`(内部契约)
- `OpportunityIntakeImpl`(建档透传 + `linkPrimaryCustomer` 尊重 `isPrimaryIntended`/`customerRole`)
- `OpportunityCreateServiceImpl`(adapter 翻译)
- 领域文档:`crm-opportunity/CONTEXT.md`(关联客户不变式放宽 + 词汇)
- 接口文档仓:`D:\code\crm-api-docs\A3 商机管理\新建商机\新建商机.bru`
## 边界(本期不做)
- 线索转入入口(`OpportunityCreationPortImpl`)透传新字段:暂不接线,LEAD_CONVERT 传 null/默认。
新字段均为 DIRECT 新建入口场景字段,线索转入的映射留待线索侧需求明确后另开票。
- 客户联系人搜索:归 A4 客户/联系人模块,A4 建成后走真源。

14
crm-opportunity/CONTEXT.md

@ -51,7 +51,7 @@ _Avoid_: 建商机(口语)、创建商机 service(那是 adapter,不是
_Avoid_: 商机来源(那是 oppSource 渠道分类)、入口(口语可叫入口,类型/字段名用 IntakeSource)、渠道
**关联客户**:
商机关联的客户(原型「添加关联客户」A3-1-1-2-4、客户信息 Tab A3-1-1-1-2)。子表 = **`opportunity_customer`(票 13)**:`customer_id` 引用 A4 客户 + 名快照、`customer_role` 角色(→crm-dict)、`is_primary_intended` **是否主要意向客户**。规则(产品拍板):一商机可多个关联客户,**有且仅一个主要意向客户**;`UNIQUE(opportunity_id, customer_id)`。**两条入口都必填主要意向客户**(直接新建表单含意向客户必选,与线索转商机 `customerId` 必填对齐;grill 补拍 20260821),保证主客户约束自创建起恒成立。线索转商机时填的意向客户 = 落为主要意向客户;商机侧可加普通关联客户、可改主要意向客户(切换标记)。主表冗余 `primary_customer_id`/名快照(免 join)。**主要意向客户是列表派生列(方案预算/方案卡状态)的取值锚点**(票 07/10)。**无删除动作(grill 纠偏 20260821,原型/票 13 均无删除)**:关联客户不可单独删(仅随商机级联清理);更换主要意向客户走「改主要意向客户」切标记(事务内旧主→0/新主→1)而非删行,保证「有且仅一个主要意向客户」恒成立。**切主一致性(grill 拍板 20260821)**:一事务三步(旧主→0 / 新主→1 / 刷主表冗余)原子完成;并发复用商机主表乐观锁 `version`(切主=商机级写),子表部分唯一索引 `UNIQUE(opportunity_id) WHERE is_primary_intended=1` 作 DB 最后防线。⚠ `customer_id` 引用 + 下拉选客户能力依赖 A4 客户模块(未建),见票 11。
商机关联的客户(原型「添加关联客户」A3-1-1-2-4、客户信息 Tab A3-1-1-1-2)。子表 = **`opportunity_customer`(票 13)**:`customer_id` 引用 A4 客户 + 名快照、`customer_role` 角色(→crm-dict,`customer_role_01`~`06`)、`is_primary_intended` **是否主要意向客户**(1=主要 / 0=普通关联)。规则(产品拍板 + opp-create-fields 放宽 2026-09):一商机可多个关联客户;`UNIQUE(opportunity_id, customer_id)`。**创建时至少挂一个关联客户**(直接新建表单 `customerId`/`customerName` 非空;线索转商机 `customerId` 必填),**是否主要由 `isPrimaryIntended` 决定**(新建入口收该字段):`=0`(普通关联)时创建完成**允许零主要意向客户**,主表主要客户冗余留空(普通关联不占主客户锚点);主要意向客户允许**后补/切换**(走「添加关联客户」/「改主要意向客户」端点)。**「有且仅一个主要意向客户」的约束保留在存在主要客户时**,不再要求「自创建起恒成立」。线索转商机未传 `isPrimaryIntended` → 深模块回退 1,填的意向客户落为主要意向客户(行为不变);商机侧可加普通关联客户、可改主要意向客户(切换标记)。主表冗余 `primary_customer_id`/名快照(免 join)。**主要意向客户是列表派生列(方案预算/方案卡状态)的取值锚点**(票 07/10)。**无删除动作(grill 纠偏 20260821,原型/票 13 均无删除)**:关联客户不可单独删(仅随商机级联清理);更换主要意向客户走「改主要意向客户」切标记(事务内旧主→0/新主→1)而非删行,保证存在主要客户时有且仅有一个。**切主一致性(grill 拍板 20260821)**:一事务三步(旧主→0 / 新主→1 / 刷主表冗余)原子完成;并发复用商机主表乐观锁 `version`(切主=商机级写),子表部分唯一索引 `UNIQUE(opportunity_id) WHERE is_primary_intended=1` 作 DB 最后防线。⚠ `customer_id` 引用 + 下拉选客户能力依赖 A4 客户模块(未建),见票 11。
_Avoid_: 客户(客户是 A4 域的实体)、甲方(甲方是快照里的一个字段)、customer
**暂缓**:
@ -90,6 +90,18 @@ _Avoid_: 已成交、已完成、结束
商机主表字段 `project_amount`(decimal(14,2),选填)。销售挖掘得来的**项目整体总包金额**。一个商机一个值,默认人民币元不建币种字段。早期未必知可后编缓补。票 03 Amendment 1。
_Avoid_: 方案预算(方案卡字段,不同层次)、预计金额(旧名,已弃)、成交金额(项目阶段才有)、合同金额
**项目概况**:
商机主表字段 `remark`(text,选填)。**接口展示名「项目概况」**,字段/列名仍 `remark`——同字段改名,非新列(opp-create-fields 拍板 2026-09,对称原型「项目概况」多行文本)。线索转商机时应映射来源线索对应字段(映射接线归线索侧另票)。
_Avoid_: 备注(接口展示名弃用)、新增 remark 列(同字段改名非新列)、项目描述
**项目详细地址**:
商机主表字段 `address`(varchar(255),选填;opp-create-fields 票 01)。省/市/区之外的**门牌地址文本**补充,形状与语义对称线索的 `address`
_Avoid_: 项目区域(那是省/市/区国标 code 级联)、详细地址=地理坐标(纯文本无结构化坐标)
**商机关键联系人**:
商机维度的关键联系人快照,三列落主表(opp-create-fields 票 01):`key_contact_id`(引用 A4 联系人,**A4 未建可空**)+ `key_contact_name_snapshot` 姓名 + `key_contact_company_snapshot` 关键人所在公司。新建入口选填(选完关键联系人后前端带出姓名/公司快照)。**与「客户联系人」区分**——客户联系人归 A4 客户域(客户维度的联系人档案,新建表单的客户联系人搜索待 A4 建成后走真源)。
_Avoid_: 客户联系人(归 A4 客户域)、主要联系人(无主要/次要排序语义)
**方案预算**:
方案卡子表字段 `scheme_budget`(票 07)。销售对该商机下某个**方案卡**填的金额。**一客户一卡**(`UNIQUE(opportunity_id, customer_id)`),一商机多客户 → 多卡多预算。商机列表「方案预算」列 = 查**主要意向客户**(票 13)那张方案卡的预算,主表不冗余。**不做主卡(`is_primary`)**——取值锚点是主要意向客户(客户级),非卡级。
_Avoid_: 项目金额(商机主表字段,不同层次)、预计金额(旧名,已弃)

27
crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java

@ -2,6 +2,8 @@ package com.crm.opportunity.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 商机侧直接新建商机请求 12新建入口 A3-1-1-2-1
*
@ -48,9 +50,30 @@ public class CreateOpportunityRequest {
/** 省国标 code。 */
private String provinceCode;
/** 市国标 code。 */
/** 市国标 code(对称 lead 约定:市码或区/县码均可,不单建 district_code;前端三级级联选到区/县时传区/县 code)。 */
private String cityCode;
/** 备注。 */
/** 项目概况(展示名;字段/列名仍 {@code remark},同字段改名非新列)。线索转商机时应映射来源线索对应字段。 */
private String remark;
/** 项目金额(项目整体总包,人民币元,选填可后补;实体 project_amount 已存在,DTO 原漏带)。 */
private BigDecimal projectAmount;
/** 项目详细地址(省/市/区之外的门牌地址文本,选填;形状对齐 Lead.address)。 */
private String address;
/** 客户类型 → 关联客户角色字典 code(customer_role;customerId 非空时必填)。 */
private String customerRole;
/** 是否主要客户:1=主要意向客户 / 0=普通关联客户(customerId 非空时必填;0 不刷主客户冗余)。 */
private Integer isPrimaryIntended;
/** 商机关键联系人 ID(引用 A4 联系人,未建可空)。 */
private Long keyContactId;
/** 商机关键联系人姓名快照(keyContactId 非空时前端带出,可空)。 */
private String keyContactName;
/** 关键人所在公司快照(keyContactId 非空时前端带出,可空)。 */
private String keyContactCompany;
}

18
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java

@ -89,6 +89,10 @@ public class Opportunity extends BaseEntity {
@Column(columnDefinition = "decimal(14,2) comment '项目金额'")
private BigDecimal projectAmount;
@Comment("项目详细地址(省/市/区之外的门牌地址文本;对称 lead 的 address,opp-create-fields 票 01)")
@Column(columnDefinition = "varchar(255) comment '项目详细地址'")
private String address;
// -------- 簇 B1 归属(票 01/02)--------
@Comment("领取人ID(归属+可见性;抛公海/回收时清空)")
@ -176,6 +180,20 @@ public class Opportunity extends BaseEntity {
@Column(columnDefinition = "varchar(200) comment '主客户名快照'")
private String primaryCustomerNameSnapshot;
// -------- C5 商机关键联系人快照(opp-create-fields 票 01,A4 联系人未建可空)--------
@Comment("商机关键联系人ID(引用 A4 联系人,未建可空)")
@Column(columnDefinition = "bigint comment '商机关键联系人ID'")
private Long keyContactId;
@Comment("商机关键联系人姓名快照")
@Column(columnDefinition = "varchar(200) comment '商机关键联系人姓名快照'")
private String keyContactNameSnapshot;
@Comment("关键人所在公司快照(选完关键联系人后前端带出)")
@Column(columnDefinition = "varchar(200) comment '关键人所在公司快照'")
private String keyContactCompanySnapshot;
// -------- C4 方案卡快照(D-17:主客户卡提交事务内回写,详情头部免 join 回显)--------
@Comment("方案卡预算快照(D-17:主客户方案卡提交时回写 card.scheme_budget;详情头部「方案预算」展示源)")

36
crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java

@ -1,5 +1,7 @@
package com.crm.opportunity.intake;
import java.math.BigDecimal;
/**
* 商机建档规格两条入口翻译后的统一内部契约 02/11/12/13/16
*
@ -18,15 +20,22 @@ package com.crm.opportunity.intake;
* @param partyA 甲方自由文本可空
* @param provinceCode 省国标 code
* @param cityCode 市国标 code
* @param remark 备注可空
* @param ownerUserId 领取人 = 创建人 = 商机负责人
* @param ownerDeptId 领取人所属部门快照阶段模板按部门解析可空 默认模板兜底
* @param customerId 主要意向客户 id可空非空则建 is_primary_intended 子表 + 刷主表冗余
* @param intendedCustomer 意向客户名快照
* @param sourceLeadId 来源线索 id唯一性预检 keyDIRECT 非线索来源时为 null不参与 UNIQUE
* @param sourceLeadName 线索名称快照可空
* @param sourcePhone 联系电话快照可空
* @param sourceProductCode 需求产品 code 快照可空
* @param remark 备注/项目概况可空
* @param ownerUserId 领取人 = 创建人 = 商机负责人
* @param ownerDeptId 领取人所属部门快照阶段模板按部门解析可空 默认模板兜底
* @param customerId 关联客户 id可空非空则建 opportunity_customer 子表
* @param intendedCustomer 客户名快照
* @param sourceLeadId 来源线索 id唯一性预检 keyDIRECT 非线索来源时为 null不参与 UNIQUE
* @param sourceLeadName 线索名称快照可空
* @param sourcePhone 联系电话快照可空
* @param sourceProductCode 需求产品 code 快照可空
* @param projectAmount 项目金额选填直接落主表 project_amount
* @param address 项目详细地址选填直接落主表 address
* @param customerRole 关联客户角色字典 code非空则子表 customer_role 用之空则回退 ROLE_INTENDED
* @param isPrimaryIntended 是否主要意向客户 1/0非空则子表用之空则回退 1
* @param keyContactId 商机关键联系人 ID引用 A4 联系人未建可空
* @param keyContactName 商机关键联系人姓名快照
* @param keyContactCompany 关键人所在公司快照
*/
public record OpportunityIntakeSpec(
IntakeSource source,
@ -48,6 +57,13 @@ public record OpportunityIntakeSpec(
Long sourceLeadId,
String sourceLeadName,
String sourcePhone,
String sourceProductCode
String sourceProductCode,
BigDecimal projectAmount,
String address,
String customerRole,
Integer isPrimaryIntended,
Long keyContactId,
String keyContactName,
String keyContactCompany
) {
}

36
crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java

@ -103,6 +103,13 @@ public class OpportunityIntakeImpl implements OpportunityIntake {
opp.setIndustryCode(spec.industryCode() != null ? spec.industryCode() : "");
opp.setPartyA(spec.partyA());
opp.setRemark(spec.remark());
// opp-create-fields 票 01:项目金额 / 项目详细地址(选填直接落主表)
opp.setProjectAmount(spec.projectAmount());
opp.setAddress(spec.address());
// opp-create-fields 票 01:商机关键联系人快照(引用 A4 联系人,未建可空)
opp.setKeyContactId(spec.keyContactId());
opp.setKeyContactNameSnapshot(spec.keyContactName());
opp.setKeyContactCompanySnapshot(spec.keyContactCompany());
opp.setOwnerUserId(spec.ownerUserId());
opp.setOwnerDeptId(spec.ownerDeptId());
opp.setCreatorUserId(spec.ownerUserId());
@ -114,8 +121,8 @@ public class OpportunityIntakeImpl implements OpportunityIntake {
// 新建即领取,进入推进中;领取时间落库
opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue());
opp.setClaimTime(LocalDateTime.now());
// 意向客户名快照(引用 + 快照并存);customerId 非空时下方刷主表冗余
opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer());
// 主表主要客户冗余(primary_customer_id / primary_customer_name_snapshot)不在此处无条件刷:
// 由 linkPrimaryCustomer 按 isPrimaryIntended 分支决定(=1 刷并回写、=0 留空不回写、customerId 空则跳过)。
// 票 02 步 2:阶段落位——按负责人部门解析发布中模板 + 首节点(无模板 fail-fast,见 applyInitialStage)
applyInitialStage(opp, spec);
return opp;
@ -173,21 +180,30 @@ public class OpportunityIntakeImpl implements OpportunityIntake {
teamMapper.insert(member);
}
/** 票 13:建 is_primary_intended=1 主要意向客户子表 + 刷主表冗余 primary_customer_id/name。 */
/**
* 13 + opp-create-fields 02建关联客户子表 + 按是否主要分支刷主表冗余
*
* <p>customerRole 非空用 spec客户类型空则回退 {@link #ROLE_INTENDED}保持线索转入路径兼容
* isPrimaryIntended 非空用 spec1 0空则回退 1主表主要客户冗余只在 isPrimaryIntended==1
* 时刷并回写普通关联=0 不占主客户锚点主表无变更不触发空转 UPDATE/乐观锁 +1</p>
*/
private void linkPrimaryCustomer(Opportunity opp, OpportunityIntakeSpec spec) {
Integer primaryIntended = spec.isPrimaryIntended() != null ? spec.isPrimaryIntended() : 1;
OpportunityCustomer link = new OpportunityCustomer();
link.setOpportunityId(opp.getId());
link.setCustomerId(spec.customerId());
link.setCustomerNameSnapshot(spec.intendedCustomer());
link.setCustomerRole(ROLE_INTENDED);
link.setIsPrimaryIntended(1);
link.setCustomerRole(spec.customerRole() != null ? spec.customerRole() : ROLE_INTENDED);
link.setIsPrimaryIntended(primaryIntended);
customerMapper.insert(link);
// 主表冗余(票 03 C3):主要意向客户 id/名,高频展示免 join
opp.setPrimaryCustomerId(spec.customerId());
if (StrUtil.isNotBlank(spec.intendedCustomer())) {
opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer());
// 主表冗余(票 03 C3):仅主要意向客户(=1) 刷 id/名;普通关联(=0) 留空不占主客户锚点、不回写主表
if (primaryIntended == 1) {
opp.setPrimaryCustomerId(spec.customerId());
if (StrUtil.isNotBlank(spec.intendedCustomer())) {
opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer());
}
oppMapper.updateById(opp);
}
oppMapper.updateById(opp);
}
}

5
crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java

@ -55,7 +55,10 @@ public class OpportunityCreationPortImpl implements OpportunityCreationPort {
cmd.sourceLeadId(),
cmd.sourceLeadName(),
cmd.sourcePhone(),
cmd.sourceProductCode());
cmd.sourceProductCode(),
// opp-create-fields:新字段均为 DIRECT 新建入口场景字段,线索转入暂不接线(LEAD_CONVERT 传 null,
// 走深模块回退:customerRole→ROLE_INTENDED、isPrimaryIntended→1),映射留待线索侧需求明确后另开票。
null, null, null, null, null, null, null);
try {
return intake.open(spec);
} catch (OpportunityIntakeException e) {

22
crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java

@ -69,6 +69,19 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService {
throw new BusinessErrorException("商机来源为「线索转入」时必须选择关联线索");
}
// opp-create-fields 票 03:customerId 非空时关联客户字段必填
if (request.getCustomerId() != null) {
if (request.getIsPrimaryIntended() == null) {
throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "是否主要客户不能为空");
}
if (request.getIsPrimaryIntended() != 1 && request.getIsPrimaryIntended() != 0) {
throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "是否主要客户取值仅 1(主要意向客户)或 0(普通关联客户)");
}
if (StrUtil.isBlank(request.getCustomerRole())) {
throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "客户类型不能为空");
}
}
OpportunityIntakeSpec spec = new OpportunityIntakeSpec(
IntakeSource.DIRECT,
request.getOppSource(),
@ -90,7 +103,14 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService {
request.getSourceLeadId(),
null,
null,
null);
null,
request.getProjectAmount(),
request.getAddress(),
request.getCustomerRole(),
request.getIsPrimaryIntended(),
request.getKeyContactId(),
request.getKeyContactName(),
request.getKeyContactCompany());
try {
return intake.open(spec);
} catch (OpportunityIntakeException e) {

19
crm-opportunity/src/test/java/com/crm/opportunity/domain/entity/OpportunityTest.java

@ -52,13 +52,15 @@ class OpportunityTest {
}
@Test
@DisplayName("字段全集 = 票 03 三簇 + 暂缓附加 + Amendment 1 项目金额 + 乐观锁,无漏无多")
@DisplayName("字段全集 = 票 03 三簇 + 暂缓附加 + Amendment 1 项目金额 + 乐观锁 + opp-create-fields 票 01 四列,无漏无多")
void fieldSet_exactlyTicket03() {
assertThat(FIELDS).containsExactlyInAnyOrder(
// 簇 A 业务字段(PRD §2.1,原型 A3-1-1-2-1)
"oppName", "oppSource", "oppType", "industryCode", "bidForm",
"localityType", "partyAClear", "partyA", "provinceCode", "cityCode",
"remark", "projectAmount",
// 簇 A 业务字段(opp-create-fields 票 01):项目详细地址
"address",
// 簇 B1 归属
"ownerUserId", "ownerNameSnapshot", "ownerDeptId", "originDeptId",
"poolReason", "creatorUserId",
@ -70,6 +72,8 @@ class OpportunityTest {
"claimTime", "lastValidFollowTime",
// C3 主要意向客户冗余快照
"primaryCustomerId", "primaryCustomerNameSnapshot",
// C5 商机关键联系人快照(opp-create-fields 票 01,A4 联系人未建可空)
"keyContactId", "keyContactNameSnapshot", "keyContactCompanySnapshot",
// C4 方案卡快照(票 06 D-17:主客户卡提交回写,详情头部免 join 回显)
"schemeBudget", "schemeCardStatus",
// 暂缓附加字段(PRD §2.2,E4 写 E5 清空)
@ -196,6 +200,19 @@ class OpportunityTest {
assertThat(columnDef("schemeCardStatus")).contains("int").doesNotContain("not null");
}
@Test
@DisplayName("opp-create-fields 票 01:address varchar(255) 选填;关键联系人三列可空(id bigint + 双 varchar(200))")
void oppCreateFields_columns() throws NoSuchFieldException {
assertThat(field("address").getType()).isEqualTo(String.class);
assertThat(columnDef("address")).contains("varchar(255)").doesNotContain("not null");
assertThat(field("keyContactId").getType()).isEqualTo(Long.class);
assertThat(columnDef("keyContactId")).contains("bigint").doesNotContain("not null");
assertThat(field("keyContactNameSnapshot").getType()).isEqualTo(String.class);
assertThat(columnDef("keyContactNameSnapshot")).contains("varchar(200)").doesNotContain("not null");
assertThat(field("keyContactCompanySnapshot").getType()).isEqualTo(String.class);
assertThat(columnDef("keyContactCompanySnapshot")).contains("varchar(200)").doesNotContain("not null");
}
@Test
@DisplayName("簇 C1:继承 BaseEntity(id/审计四列/逻辑删除复用,不自建)")
void extendsBaseEntity() {

92
crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java

@ -25,6 +25,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ -42,6 +43,10 @@ import static org.mockito.Mockito.when;
* <p>覆盖建档机制阶段落位命中 / 无发布中模板 fail-fast 拒绝938aed9初始日志两来源分支领取人团队成员
* 主要意向客户子表/ customerId初始状态推进中 + 快照source_lead_id 唯一性
* 预检 + 并发 DuplicateKey 转译adapter 侧的异常翻译由各自 adapter 测试覆盖</p>
*
* <p>opp-create-fields 02 新字段项目金额/项目详细地址/关键联系人快照建档透传
* 关联客户子表按 {@code isPrimaryIntended}/{@code customerRole} spec 取值分支
* =0 普通关联不刷主表冗余空回退 ROLE_INTENDED/1 保持线索转入路径兼容</p>
*/
@DisplayName("商机建档深模块 OpportunityIntake")
@ExtendWith(MockitoExtension.class)
@ -64,22 +69,38 @@ class OpportunityIntakeImplTest {
@InjectMocks
private OpportunityIntakeImpl intake;
/** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01)。 */
private static final Long KEY_CONTACT_ID = 88001L;
/** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01;新字段传 null 走深模块回退)。 */
private OpportunityIntakeSpec leadConvertSpec(Long customerId) {
return new OpportunityIntakeSpec(
IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A",
null, null, null, null,
"ind01", "甲方X", "110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID,
customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01");
customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01",
null, null, null, null, null, null, null);
}
/** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索)。 */
/** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索;新字段不涉及)。 */
private OpportunityIntakeSpec directSpec(String oppSource, Long customerId) {
return new OpportunityIntakeSpec(
IntakeSource.DIRECT, oppSource, "商机B",
null, null, null, null,
"ind02", "甲方Y", "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID,
customerId, "意向客户名2", null, null, null, null);
customerId, "意向客户名2", null, null, null, null,
null, null, null, null, null, null, null);
}
/** 直接创建 spec(含 opp-create-fields 新字段:金额/地址/客户角色/是否主要/关键联系人快照)。 */
private OpportunityIntakeSpec directFullSpec(Long customerId, String customerRole, Integer isPrimaryIntended) {
return new OpportunityIntakeSpec(
IntakeSource.DIRECT, "opp_source_02", "商机B",
null, null, null, null,
"ind02", "甲方Y", "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID,
customerId, "意向客户名2", null, null, null, null,
new BigDecimal("880000.00"), "成都市高新区天府大道1号",
customerRole, isPrimaryIntended,
KEY_CONTACT_ID, "王工", "甲方Y公司");
}
/** 桩定阶段模板命中:部门解析到发布中版本 + 首节点。 */
@ -139,7 +160,7 @@ class OpportunityIntakeImplTest {
}
@Test
@DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照")
@DisplayName("新建商机初始状态=推进中(2),落来源快照;customerId 空不刷主客户冗余(票02)")
void open_setsAdvancingAndSnapshots() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubStageTemplateHit();
@ -155,7 +176,9 @@ class OpportunityIntakeImplTest {
assertThat(opp.getSourceLeadName()).isEqualTo("线索A");
assertThat(opp.getSourcePhone()).isEqualTo("13800000000");
assertThat(opp.getSourceProductCode()).isEqualTo("prod01");
assertThat(opp.getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名");
// opp-create-fields 票02:主客户冗余不再无条件刷(旧版 buildOpportunity 会先落名快照),
// customerId 空时留空,由 linkPrimaryCustomer 按 isPrimaryIntended 分支管理
assertThat(opp.getPrimaryCustomerNameSnapshot()).isNull();
}
@Test
@ -202,7 +225,7 @@ class OpportunityIntakeImplTest {
}
@Test
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余")
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余(customerRole 空回退 intended)")
void open_withCustomer_linksPrimary() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubStageTemplateHit();
@ -216,9 +239,45 @@ class OpportunityIntakeImplTest {
assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID);
assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID);
assertThat(link.getIsPrimaryIntended()).isEqualTo(1);
assertThat(link.getCustomerRole()).isEqualTo("intended");
verify(oppMapper).updateById(any(Opportunity.class));
}
@Test
@DisplayName("opp-create-fields 票02:DIRECT 新字段(金额/地址/关键联系人快照)透传落主表")
void open_directCreate_persistsNewFields() {
stubStageTemplateHit();
stubInsertAssignsId();
ArgumentCaptor<Opportunity> captor = ArgumentCaptor.forClass(Opportunity.class);
intake.open(directFullSpec(null, null, null));
verify(oppMapper).insert(captor.capture());
Opportunity opp = captor.getValue();
assertThat(opp.getProjectAmount()).isEqualByComparingTo(new BigDecimal("880000.00"));
assertThat(opp.getAddress()).isEqualTo("成都市高新区天府大道1号");
assertThat(opp.getKeyContactId()).isEqualTo(KEY_CONTACT_ID);
assertThat(opp.getKeyContactNameSnapshot()).isEqualTo("王工");
assertThat(opp.getKeyContactCompanySnapshot()).isEqualTo("甲方Y公司");
}
@Test
@DisplayName("opp-create-fields 票02:isPrimaryIntended=0 普通关联 → 子表落 0/role 用 spec 值,主表不回写(无空转 UPDATE)")
void open_ordinaryLink_noPrimaryAnchor() {
stubStageTemplateHit();
stubInsertAssignsId();
ArgumentCaptor<OpportunityCustomer> linkCap = ArgumentCaptor.forClass(OpportunityCustomer.class);
intake.open(directFullSpec(CUSTOMER_ID, "customer_role_02", 0));
verify(customerMapper).insert(linkCap.capture());
OpportunityCustomer link = linkCap.getValue();
assertThat(link.getIsPrimaryIntended()).isZero();
assertThat(link.getCustomerRole()).isEqualTo("customer_role_02");
// 普通关联主表无变更:不回写、不触发乐观锁 +1(code-review P2 采纳)
verify(oppMapper, never()).updateById(any(Opportunity.class));
}
@Test
@DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机")
void open_noCustomer_skipsLink() {
@ -282,4 +341,23 @@ class OpportunityIntakeImplTest {
assertThat(member.getProjectRole()).isEqualTo("project_role_01");
assertThat(member.getPermission()).isEqualTo(TeamMemberPermission.READ_WRITE.getValue());
}
@Test
@DisplayName("opp-create-fields 票02:customerId 非空 + isPrimaryIntended=1 → 子表落 1/role 用 spec 值 + 刷主表冗余")
void open_primaryLink_usesSpecValues() {
stubStageTemplateHit();
stubInsertAssignsId();
ArgumentCaptor<OpportunityCustomer> linkCap = ArgumentCaptor.forClass(OpportunityCustomer.class);
ArgumentCaptor<Opportunity> oppCap = ArgumentCaptor.forClass(Opportunity.class);
intake.open(directFullSpec(CUSTOMER_ID, "customer_role_01", 1));
verify(customerMapper).insert(linkCap.capture());
OpportunityCustomer link = linkCap.getValue();
assertThat(link.getIsPrimaryIntended()).isEqualTo(1);
assertThat(link.getCustomerRole()).isEqualTo("customer_role_01");
verify(oppMapper).updateById(oppCap.capture());
assertThat(oppCap.getValue().getPrimaryCustomerId()).isEqualTo(CUSTOMER_ID);
assertThat(oppCap.getValue().getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名2");
}
}

80
crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.lead.port.inbound.ConvertibleLeadCatalogPort;
import com.crm.lead.port.inbound.ConvertibleLeadView;
import com.crm.opportunity.constant.OpportunityConstants;
import com.crm.opportunity.domain.dto.CreateOpportunityRequest;
import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.intake.IntakeSource;
@ -24,6 +25,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ -39,7 +41,8 @@ import static org.mockito.Mockito.when;
* <p>覆盖oppSource=线索转入时 sourceLeadId 必填校验不进深模块
* {@code CreateOpportunityRequest} {@code OpportunityIntakeSpec} 全字段映射
* {@code OpportunityIntakeException} {@code BusinessErrorException} seam 翻译
* 候选线索剔除已占用准入来自 {@code ConvertibleLeadCatalogPort}ADR-0031
* 候选线索剔除已占用准入来自 {@code ConvertibleLeadCatalogPort}ADR-0031
* opp-create-fields 票03customerId 非空时 isPrimaryIntended/customerRole 校验与新字段翻译
* 建档五步机制与唯一性防护的断言已上移
* {@code OpportunityIntakeImplTest}本类不再重复</p>
*/
@ -82,6 +85,15 @@ class OpportunityCreateServiceImplTest {
// D-14:核心必填集(不齐 66001 先拦,到不了翻译/线索校验分支)
r.setBidForm("bid_form_01");
r.setLocalityType("locality_type_01");
// opp-create-fields 票03:customerId 非空时关联客户字段合法值(默认主要意向客户)
r.setCustomerRole("customer_role_01");
r.setIsPrimaryIntended(1);
// opp-create-fields 票03:新建入口场景字段(选填)
r.setProjectAmount(new BigDecimal("880000.00"));
r.setAddress("成都市高新区天府大道1号");
r.setKeyContactId(88001L);
r.setKeyContactName("王工");
r.setKeyContactCompany("甲方X公司");
return r;
}
@ -126,6 +138,14 @@ class OpportunityCreateServiceImplTest {
assertThat(spec.sourceLeadName()).isNull();
assertThat(spec.sourcePhone()).isNull();
assertThat(spec.sourceProductCode()).isNull();
// opp-create-fields 票03:新字段翻译(金额/地址/客户角色/是否主要/关键联系人快照)
assertThat(spec.projectAmount()).isEqualByComparingTo(new BigDecimal("880000.00"));
assertThat(spec.address()).isEqualTo("成都市高新区天府大道1号");
assertThat(spec.customerRole()).isEqualTo("customer_role_01");
assertThat(spec.isPrimaryIntended()).isEqualTo(1);
assertThat(spec.keyContactId()).isEqualTo(88001L);
assertThat(spec.keyContactName()).isEqualTo("王工");
assertThat(spec.keyContactCompany()).isEqualTo("甲方X公司");
}
@Test
@ -140,6 +160,64 @@ class OpportunityCreateServiceImplTest {
.hasMessageContaining("已关联");
}
@Test
@DisplayName("opp-create-fields 票03:customerId 非空但未传 isPrimaryIntended → 66001,不进深模块")
void create_customerWithoutPrimaryFlag_rejected() {
CreateOpportunityRequest r = req("opp_source_02", null);
r.setIsPrimaryIntended(null);
assertThatThrownBy(() -> service.createOpportunity(r, OWNER))
.isInstanceOfSatisfying(BusinessErrorException.class, e ->
assertThat(e.getCode()).isEqualTo(OpportunityConstants.CODE_OPP_INVALID))
.hasMessageContaining("是否主要客户");
verify(intake, never()).open(any(OpportunityIntakeSpec.class));
}
@Test
@DisplayName("opp-create-fields 票03:isPrimaryIntended 取值非 1/0 → 66001")
void create_customerInvalidPrimaryFlag_rejected() {
CreateOpportunityRequest r = req("opp_source_02", null);
r.setIsPrimaryIntended(2);
assertThatThrownBy(() -> service.createOpportunity(r, OWNER))
.isInstanceOfSatisfying(BusinessErrorException.class, e ->
assertThat(e.getCode()).isEqualTo(OpportunityConstants.CODE_OPP_INVALID))
.hasMessageContaining("取值仅");
verify(intake, never()).open(any(OpportunityIntakeSpec.class));
}
@Test
@DisplayName("opp-create-fields 票03:customerId 非空但 customerRole 空白 → 66001")
void create_customerWithoutRole_rejected() {
CreateOpportunityRequest r = req("opp_source_02", null);
r.setCustomerRole(" ");
assertThatThrownBy(() -> service.createOpportunity(r, OWNER))
.isInstanceOfSatisfying(BusinessErrorException.class, e ->
assertThat(e.getCode()).isEqualTo(OpportunityConstants.CODE_OPP_INVALID))
.hasMessageContaining("客户类型");
verify(intake, never()).open(any(OpportunityIntakeSpec.class));
}
@Test
@DisplayName("opp-create-fields 票03:customerId 为空 → 关联客户字段校验忽略(A4 未建),照常进深模块")
void create_noCustomer_skipsCustomerValidation() {
when(ownerSnapshotResolver.of(OWNER)).thenReturn(new OwnerSnapshot(OWNER, "测试创建人", 55L));
when(intake.open(any(OpportunityIntakeSpec.class))).thenReturn(NEW_OPP_ID);
CreateOpportunityRequest r = req("opp_source_02", null);
r.setCustomerId(null);
r.setIsPrimaryIntended(2); // 非法值也不拦:customerId 为空时忽略
Long id = service.createOpportunity(r, OWNER);
assertThat(id).isEqualTo(NEW_OPP_ID);
ArgumentCaptor<OpportunityIntakeSpec> cap = ArgumentCaptor.forClass(OpportunityIntakeSpec.class);
verify(intake).open(cap.capture());
assertThat(cap.getValue().customerId()).isNull();
// adapter 不清洗非法值,深模块在 customerId 为空时整体忽略关联客户分支
assertThat(cap.getValue().isPrimaryIntended()).isEqualTo(2);
}
@Test
@DisplayName("候选线索:剔除已被商机占用的 source_lead_id")
void listConvertibleLeads_excludesTaken() {

Loading…
Cancel
Save