diff --git a/.scratch/opportunity-create-fields/issues/01-entity-dto-fields.md b/.scratch/opportunity-create-fields/issues/01-entity-dto-fields.md new file mode 100644 index 0000000..f4ba302 --- /dev/null +++ b/.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 扫描干净 diff --git a/.scratch/opportunity-create-fields/issues/02-intake-spec.md b/.scratch/opportunity-create-fields/issues/02-intake-spec.md new file mode 100644 index 0000000..e69b25b --- /dev/null +++ b/.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` 通过 diff --git a/.scratch/opportunity-create-fields/issues/03-create-adapter.md b/.scratch/opportunity-create-fields/issues/03-create-adapter.md new file mode 100644 index 0000000..dbf7718 --- /dev/null +++ b/.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` 通过 diff --git a/.scratch/opportunity-create-fields/issues/04-context-doc.md b/.scratch/opportunity-create-fields/issues/04-context-doc.md new file mode 100644 index 0000000..0970fec --- /dev/null +++ b/.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 三处更新,无矛盾残留 diff --git a/.scratch/opportunity-create-fields/issues/05-bruno-doc.md b/.scratch/opportunity-create-fields/issues/05-bruno-doc.md new file mode 100644 index 0000000..7abe097 --- /dev/null +++ b/.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 一致 diff --git a/.scratch/opportunity-create-fields/spec.md b/.scratch/opportunity-create-fields/spec.md new file mode 100644 index 0000000..0df97fd --- /dev/null +++ b/.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 建成后走真源。 diff --git a/crm-opportunity/CONTEXT.md b/crm-opportunity/CONTEXT.md index a7dda4c..c24177a 100644 --- a/crm-opportunity/CONTEXT.md +++ b/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_: 项目金额(商机主表字段,不同层次)、预计金额(旧名,已弃) diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java index 9f594b0..49e2e56 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java +++ b/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; } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java index 9f46f4d..05edd30 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java +++ b/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;详情头部「方案预算」展示源)") diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java index 37391b5..f6b2239 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java +++ b/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(唯一性预检 key;DIRECT 非线索来源时为 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(唯一性预检 key;DIRECT 非线索来源时为 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 ) { } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java index 2011015..a40adb0 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java +++ b/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:建关联客户子表 + 按是否主要分支刷主表冗余。 + * + *

customerRole 非空用 spec(客户类型),空则回退 {@link #ROLE_INTENDED}(保持线索转入路径兼容); + * isPrimaryIntended 非空用 spec(1 或 0),空则回退 1。主表主要客户冗余只在 isPrimaryIntended==1 + * 时刷并回写(普通关联=0 不占主客户锚点,主表无变更不触发空转 UPDATE/乐观锁 +1)。

+ */ 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); } } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java index ca315c8..ada6055 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java +++ b/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) { diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java index fd5c767..5717d3f 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java +++ b/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) { diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/domain/entity/OpportunityTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/domain/entity/OpportunityTest.java index 3af3adb..ee876a9 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/domain/entity/OpportunityTest.java +++ b/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() { diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java index 53670ed..73e097a 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java +++ b/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; *

覆盖建档机制:阶段落位(命中 / 无发布中模板 fail-fast 拒绝,938aed9)、初始日志两来源分支、领取人团队成员、 * 主要意向客户子表(有/无 customerId)、初始状态推进中 + 快照、source_lead_id 唯一性 * 预检 + 并发 DuplicateKey 转译。adapter 侧的异常翻译由各自 adapter 测试覆盖。

+ * + *

opp-create-fields 票 02 补:新字段(项目金额/项目详细地址/关键联系人快照)建档透传、 + * 关联客户子表按 {@code isPrimaryIntended}/{@code customerRole} spec 取值分支 + * (=0 普通关联不刷主表冗余;空回退 ROLE_INTENDED/1 保持线索转入路径兼容)。

*/ @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 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 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 linkCap = ArgumentCaptor.forClass(OpportunityCustomer.class); + ArgumentCaptor 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"); + } } diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java index 2df0fdb..a909282 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java +++ b/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; *

覆盖:oppSource=线索转入时 sourceLeadId 必填校验(不进深模块)、 * {@code CreateOpportunityRequest} → {@code OpportunityIntakeSpec} 全字段映射、 * {@code OpportunityIntakeException} → {@code BusinessErrorException} seam 翻译、 - * 候选线索剔除已占用(准入来自 {@code ConvertibleLeadCatalogPort},ADR-0031)。 + * 候选线索剔除已占用(准入来自 {@code ConvertibleLeadCatalogPort},ADR-0031)、 + * opp-create-fields 票03:customerId 非空时 isPrimaryIntended/customerRole 校验与新字段翻译。 * 建档五步机制与唯一性防护的断言已上移 * {@code OpportunityIntakeImplTest},本类不再重复。

*/ @@ -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 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() {