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 7 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. 24
      crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java
  11. 28
      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)、渠道 _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 _Avoid_: 客户(客户是 A4 域的实体)、甲方(甲方是快照里的一个字段)、customer
**暂缓**: **暂缓**:
@ -90,6 +90,18 @@ _Avoid_: 已成交、已完成、结束
商机主表字段 `project_amount`(decimal(14,2),选填)。销售挖掘得来的**项目整体总包金额**。一个商机一个值,默认人民币元不建币种字段。早期未必知可后编缓补。票 03 Amendment 1。 商机主表字段 `project_amount`(decimal(14,2),选填)。销售挖掘得来的**项目整体总包金额**。一个商机一个值,默认人民币元不建币种字段。早期未必知可后编缓补。票 03 Amendment 1。
_Avoid_: 方案预算(方案卡字段,不同层次)、预计金额(旧名,已弃)、成交金额(项目阶段才有)、合同金额 _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`)**——取值锚点是主要意向客户(客户级),非卡级。 方案卡子表字段 `scheme_budget`(票 07)。销售对该商机下某个**方案卡**填的金额。**一客户一卡**(`UNIQUE(opportunity_id, customer_id)`),一商机多客户 → 多卡多预算。商机列表「方案预算」列 = 查**主要意向客户**(票 13)那张方案卡的预算,主表不冗余。**不做主卡(`is_primary`)**——取值锚点是主要意向客户(客户级),非卡级。
_Avoid_: 项目金额(商机主表字段,不同层次)、预计金额(旧名,已弃) _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 lombok.Data;
import java.math.BigDecimal;
/** /**
* 商机侧直接新建商机请求 12新建入口 A3-1-1-2-1 * 商机侧直接新建商机请求 12新建入口 A3-1-1-2-1
* *
@ -48,9 +50,30 @@ public class CreateOpportunityRequest {
/** 省国标 code。 */ /** 省国标 code。 */
private String provinceCode; private String provinceCode;
/** 市国标 code。 */ /** 市国标 code(对称 lead 约定:市码或区/县码均可,不单建 district_code;前端三级级联选到区/县时传区/县 code)。 */
private String cityCode; private String cityCode;
/** 备注。 */ /** 项目概况(展示名;字段/列名仍 {@code remark},同字段改名非新列)。线索转商机时应映射来源线索对应字段。 */
private String 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 '项目金额'") @Column(columnDefinition = "decimal(14,2) comment '项目金额'")
private BigDecimal projectAmount; private BigDecimal projectAmount;
@Comment("项目详细地址(省/市/区之外的门牌地址文本;对称 lead 的 address,opp-create-fields 票 01)")
@Column(columnDefinition = "varchar(255) comment '项目详细地址'")
private String address;
// -------- 簇 B1 归属(票 01/02)-------- // -------- 簇 B1 归属(票 01/02)--------
@Comment("领取人ID(归属+可见性;抛公海/回收时清空)") @Comment("领取人ID(归属+可见性;抛公海/回收时清空)")
@ -176,6 +180,20 @@ public class Opportunity extends BaseEntity {
@Column(columnDefinition = "varchar(200) comment '主客户名快照'") @Column(columnDefinition = "varchar(200) comment '主客户名快照'")
private String primaryCustomerNameSnapshot; 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 回显)-------- // -------- C4 方案卡快照(D-17:主客户卡提交事务内回写,详情头部免 join 回显)--------
@Comment("方案卡预算快照(D-17:主客户方案卡提交时回写 card.scheme_budget;详情头部「方案预算」展示源)") @Comment("方案卡预算快照(D-17:主客户方案卡提交时回写 card.scheme_budget;详情头部「方案预算」展示源)")

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

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

28
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.setIndustryCode(spec.industryCode() != null ? spec.industryCode() : "");
opp.setPartyA(spec.partyA()); opp.setPartyA(spec.partyA());
opp.setRemark(spec.remark()); 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.setOwnerUserId(spec.ownerUserId());
opp.setOwnerDeptId(spec.ownerDeptId()); opp.setOwnerDeptId(spec.ownerDeptId());
opp.setCreatorUserId(spec.ownerUserId()); opp.setCreatorUserId(spec.ownerUserId());
@ -114,8 +121,8 @@ public class OpportunityIntakeImpl implements OpportunityIntake {
// 新建即领取,进入推进中;领取时间落库 // 新建即领取,进入推进中;领取时间落库
opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue()); opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue());
opp.setClaimTime(LocalDateTime.now()); opp.setClaimTime(LocalDateTime.now());
// 意向客户名快照(引用 + 快照并存);customerId 非空时下方刷主表冗余 // 主表主要客户冗余(primary_customer_id / primary_customer_name_snapshot)不在此处无条件刷:
opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer()); // 由 linkPrimaryCustomer 按 isPrimaryIntended 分支决定(=1 刷并回写、=0 留空不回写、customerId 空则跳过)。
// 票 02 步 2:阶段落位——按负责人部门解析发布中模板 + 首节点(无模板 fail-fast,见 applyInitialStage) // 票 02 步 2:阶段落位——按负责人部门解析发布中模板 + 首节点(无模板 fail-fast,见 applyInitialStage)
applyInitialStage(opp, spec); applyInitialStage(opp, spec);
return opp; return opp;
@ -173,17 +180,25 @@ public class OpportunityIntakeImpl implements OpportunityIntake {
teamMapper.insert(member); 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) { private void linkPrimaryCustomer(Opportunity opp, OpportunityIntakeSpec spec) {
Integer primaryIntended = spec.isPrimaryIntended() != null ? spec.isPrimaryIntended() : 1;
OpportunityCustomer link = new OpportunityCustomer(); OpportunityCustomer link = new OpportunityCustomer();
link.setOpportunityId(opp.getId()); link.setOpportunityId(opp.getId());
link.setCustomerId(spec.customerId()); link.setCustomerId(spec.customerId());
link.setCustomerNameSnapshot(spec.intendedCustomer()); link.setCustomerNameSnapshot(spec.intendedCustomer());
link.setCustomerRole(ROLE_INTENDED); link.setCustomerRole(spec.customerRole() != null ? spec.customerRole() : ROLE_INTENDED);
link.setIsPrimaryIntended(1); link.setIsPrimaryIntended(primaryIntended);
customerMapper.insert(link); customerMapper.insert(link);
// 主表冗余(票 03 C3):主要意向客户 id/名,高频展示免 join // 主表冗余(票 03 C3):仅主要意向客户(=1) 刷 id/名;普通关联(=0) 留空不占主客户锚点、不回写主表
if (primaryIntended == 1) {
opp.setPrimaryCustomerId(spec.customerId()); opp.setPrimaryCustomerId(spec.customerId());
if (StrUtil.isNotBlank(spec.intendedCustomer())) { if (StrUtil.isNotBlank(spec.intendedCustomer())) {
opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer()); opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer());
@ -191,3 +206,4 @@ public class OpportunityIntakeImpl implements OpportunityIntake {
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.sourceLeadId(),
cmd.sourceLeadName(), cmd.sourceLeadName(),
cmd.sourcePhone(), 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 { try {
return intake.open(spec); return intake.open(spec);
} catch (OpportunityIntakeException e) { } 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("商机来源为「线索转入」时必须选择关联线索"); 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( OpportunityIntakeSpec spec = new OpportunityIntakeSpec(
IntakeSource.DIRECT, IntakeSource.DIRECT,
request.getOppSource(), request.getOppSource(),
@ -90,7 +103,14 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService {
request.getSourceLeadId(), request.getSourceLeadId(),
null, null,
null, null,
null); null,
request.getProjectAmount(),
request.getAddress(),
request.getCustomerRole(),
request.getIsPrimaryIntended(),
request.getKeyContactId(),
request.getKeyContactName(),
request.getKeyContactCompany());
try { try {
return intake.open(spec); return intake.open(spec);
} catch (OpportunityIntakeException e) { } catch (OpportunityIntakeException e) {

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

@ -52,13 +52,15 @@ class OpportunityTest {
} }
@Test @Test
@DisplayName("字段全集 = 票 03 三簇 + 暂缓附加 + Amendment 1 项目金额 + 乐观锁,无漏无多") @DisplayName("字段全集 = 票 03 三簇 + 暂缓附加 + Amendment 1 项目金额 + 乐观锁 + opp-create-fields 票 01 四列,无漏无多")
void fieldSet_exactlyTicket03() { void fieldSet_exactlyTicket03() {
assertThat(FIELDS).containsExactlyInAnyOrder( assertThat(FIELDS).containsExactlyInAnyOrder(
// 簇 A 业务字段(PRD §2.1,原型 A3-1-1-2-1) // 簇 A 业务字段(PRD §2.1,原型 A3-1-1-2-1)
"oppName", "oppSource", "oppType", "industryCode", "bidForm", "oppName", "oppSource", "oppType", "industryCode", "bidForm",
"localityType", "partyAClear", "partyA", "provinceCode", "cityCode", "localityType", "partyAClear", "partyA", "provinceCode", "cityCode",
"remark", "projectAmount", "remark", "projectAmount",
// 簇 A 业务字段(opp-create-fields 票 01):项目详细地址
"address",
// 簇 B1 归属 // 簇 B1 归属
"ownerUserId", "ownerNameSnapshot", "ownerDeptId", "originDeptId", "ownerUserId", "ownerNameSnapshot", "ownerDeptId", "originDeptId",
"poolReason", "creatorUserId", "poolReason", "creatorUserId",
@ -70,6 +72,8 @@ class OpportunityTest {
"claimTime", "lastValidFollowTime", "claimTime", "lastValidFollowTime",
// C3 主要意向客户冗余快照 // C3 主要意向客户冗余快照
"primaryCustomerId", "primaryCustomerNameSnapshot", "primaryCustomerId", "primaryCustomerNameSnapshot",
// C5 商机关键联系人快照(opp-create-fields 票 01,A4 联系人未建可空)
"keyContactId", "keyContactNameSnapshot", "keyContactCompanySnapshot",
// C4 方案卡快照(票 06 D-17:主客户卡提交回写,详情头部免 join 回显) // C4 方案卡快照(票 06 D-17:主客户卡提交回写,详情头部免 join 回显)
"schemeBudget", "schemeCardStatus", "schemeBudget", "schemeCardStatus",
// 暂缓附加字段(PRD §2.2,E4 写 E5 清空) // 暂缓附加字段(PRD §2.2,E4 写 E5 清空)
@ -196,6 +200,19 @@ class OpportunityTest {
assertThat(columnDef("schemeCardStatus")).contains("int").doesNotContain("not null"); 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 @Test
@DisplayName("簇 C1:继承 BaseEntity(id/审计四列/逻辑删除复用,不自建)") @DisplayName("簇 C1:继承 BaseEntity(id/审计四列/逻辑删除复用,不自建)")
void extendsBaseEntity() { 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.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -42,6 +43,10 @@ import static org.mockito.Mockito.when;
* <p>覆盖建档机制阶段落位命中 / 无发布中模板 fail-fast 拒绝938aed9初始日志两来源分支领取人团队成员 * <p>覆盖建档机制阶段落位命中 / 无发布中模板 fail-fast 拒绝938aed9初始日志两来源分支领取人团队成员
* 主要意向客户子表/ customerId初始状态推进中 + 快照source_lead_id 唯一性 * 主要意向客户子表/ customerId初始状态推进中 + 快照source_lead_id 唯一性
* 预检 + 并发 DuplicateKey 转译adapter 侧的异常翻译由各自 adapter 测试覆盖</p> * 预检 + 并发 DuplicateKey 转译adapter 侧的异常翻译由各自 adapter 测试覆盖</p>
*
* <p>opp-create-fields 02 新字段项目金额/项目详细地址/关键联系人快照建档透传
* 关联客户子表按 {@code isPrimaryIntended}/{@code customerRole} spec 取值分支
* =0 普通关联不刷主表冗余空回退 ROLE_INTENDED/1 保持线索转入路径兼容</p>
*/ */
@DisplayName("商机建档深模块 OpportunityIntake") @DisplayName("商机建档深模块 OpportunityIntake")
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@ -64,22 +69,38 @@ class OpportunityIntakeImplTest {
@InjectMocks @InjectMocks
private OpportunityIntakeImpl intake; 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) { private OpportunityIntakeSpec leadConvertSpec(Long customerId) {
return new OpportunityIntakeSpec( return new OpportunityIntakeSpec(
IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A", IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A",
null, null, null, null, null, null, null, null,
"ind01", "甲方X", "110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID, "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) { private OpportunityIntakeSpec directSpec(String oppSource, Long customerId) {
return new OpportunityIntakeSpec( return new OpportunityIntakeSpec(
IntakeSource.DIRECT, oppSource, "商机B", IntakeSource.DIRECT, oppSource, "商机B",
null, null, null, null, null, null, null, null,
"ind02", "甲方Y", "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, "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 @Test
@DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照") @DisplayName("新建商机初始状态=推进中(2),落来源快照;customerId 空不刷主客户冗余(票02)")
void open_setsAdvancingAndSnapshots() { void open_setsAdvancingAndSnapshots() {
when(oppMapper.selectCount(any())).thenReturn(0L); when(oppMapper.selectCount(any())).thenReturn(0L);
stubStageTemplateHit(); stubStageTemplateHit();
@ -155,7 +176,9 @@ class OpportunityIntakeImplTest {
assertThat(opp.getSourceLeadName()).isEqualTo("线索A"); assertThat(opp.getSourceLeadName()).isEqualTo("线索A");
assertThat(opp.getSourcePhone()).isEqualTo("13800000000"); assertThat(opp.getSourcePhone()).isEqualTo("13800000000");
assertThat(opp.getSourceProductCode()).isEqualTo("prod01"); assertThat(opp.getSourceProductCode()).isEqualTo("prod01");
assertThat(opp.getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名"); // opp-create-fields 票02:主客户冗余不再无条件刷(旧版 buildOpportunity 会先落名快照),
// customerId 空时留空,由 linkPrimaryCustomer 按 isPrimaryIntended 分支管理
assertThat(opp.getPrimaryCustomerNameSnapshot()).isNull();
} }
@Test @Test
@ -202,7 +225,7 @@ class OpportunityIntakeImplTest {
} }
@Test @Test
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余") @DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余(customerRole 空回退 intended)")
void open_withCustomer_linksPrimary() { void open_withCustomer_linksPrimary() {
when(oppMapper.selectCount(any())).thenReturn(0L); when(oppMapper.selectCount(any())).thenReturn(0L);
stubStageTemplateHit(); stubStageTemplateHit();
@ -216,9 +239,45 @@ class OpportunityIntakeImplTest {
assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID); assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID);
assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID); assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID);
assertThat(link.getIsPrimaryIntended()).isEqualTo(1); assertThat(link.getIsPrimaryIntended()).isEqualTo(1);
assertThat(link.getCustomerRole()).isEqualTo("intended");
verify(oppMapper).updateById(any(Opportunity.class)); 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 @Test
@DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机") @DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机")
void open_noCustomer_skipsLink() { void open_noCustomer_skipsLink() {
@ -282,4 +341,23 @@ class OpportunityIntakeImplTest {
assertThat(member.getProjectRole()).isEqualTo("project_role_01"); assertThat(member.getProjectRole()).isEqualTo("project_role_01");
assertThat(member.getPermission()).isEqualTo(TeamMemberPermission.READ_WRITE.getValue()); 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.base.domain.exception.BusinessErrorException;
import com.crm.lead.port.inbound.ConvertibleLeadCatalogPort; import com.crm.lead.port.inbound.ConvertibleLeadCatalogPort;
import com.crm.lead.port.inbound.ConvertibleLeadView; 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.dto.CreateOpportunityRequest;
import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.intake.IntakeSource; import com.crm.opportunity.intake.IntakeSource;
@ -24,6 +25,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -39,7 +41,8 @@ import static org.mockito.Mockito.when;
* <p>覆盖oppSource=线索转入时 sourceLeadId 必填校验不进深模块 * <p>覆盖oppSource=线索转入时 sourceLeadId 必填校验不进深模块
* {@code CreateOpportunityRequest} {@code OpportunityIntakeSpec} 全字段映射 * {@code CreateOpportunityRequest} {@code OpportunityIntakeSpec} 全字段映射
* {@code OpportunityIntakeException} {@code BusinessErrorException} seam 翻译 * {@code OpportunityIntakeException} {@code BusinessErrorException} seam 翻译
* 候选线索剔除已占用准入来自 {@code ConvertibleLeadCatalogPort}ADR-0031 * 候选线索剔除已占用准入来自 {@code ConvertibleLeadCatalogPort}ADR-0031
* opp-create-fields 票03customerId 非空时 isPrimaryIntended/customerRole 校验与新字段翻译
* 建档五步机制与唯一性防护的断言已上移 * 建档五步机制与唯一性防护的断言已上移
* {@code OpportunityIntakeImplTest}本类不再重复</p> * {@code OpportunityIntakeImplTest}本类不再重复</p>
*/ */
@ -82,6 +85,15 @@ class OpportunityCreateServiceImplTest {
// D-14:核心必填集(不齐 66001 先拦,到不了翻译/线索校验分支) // D-14:核心必填集(不齐 66001 先拦,到不了翻译/线索校验分支)
r.setBidForm("bid_form_01"); r.setBidForm("bid_form_01");
r.setLocalityType("locality_type_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; return r;
} }
@ -126,6 +138,14 @@ class OpportunityCreateServiceImplTest {
assertThat(spec.sourceLeadName()).isNull(); assertThat(spec.sourceLeadName()).isNull();
assertThat(spec.sourcePhone()).isNull(); assertThat(spec.sourcePhone()).isNull();
assertThat(spec.sourceProductCode()).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 @Test
@ -140,6 +160,64 @@ class OpportunityCreateServiceImplTest {
.hasMessageContaining("已关联"); .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 @Test
@DisplayName("候选线索:剔除已被商机占用的 source_lead_id") @DisplayName("候选线索:剔除已被商机占用的 source_lead_id")
void listConvertibleLeads_excludesTaken() { void listConvertibleLeads_excludesTaken() {

Loading…
Cancel
Save