Browse Source

docs(opportunity): ADR-0030 商机建档深模块 + CONTEXT.md 术语同步(Candidate #1 收官)

- ADR-0030:OpportunityIntake 收敛两条建档入口的决策记录
  (spec 内部契约 / 事务落 open / 异常不跨 seam / IntakeSource 承载差异)
  含转商机路径漏填 oppSource 的 BUG 修正记录
- CONTEXT.md 新增术语「商机建档」(OpportunityIntake)「进入方式」(IntakeSource),
  首段补深模块指引
master
luoweijian 2 weeks ago
parent
commit
4b1ec8c9b3
  1. 10
      crm-opportunity/CONTEXT.md
  2. 44
      docs/adr/0030-opportunity-intake-deep-module.md

10
crm-opportunity/CONTEXT.md

@ -1,6 +1,6 @@
# 商机业务(crm-opportunity)
销售机会的全生命周期业务域:商机实体、两条正交轴(归属流转**状态** + 业务**阶段**)、四视图(商机公海 / 销售机会(我负责的)/ 我的关注 / 商机管理)、领取 / 分配 / 暂缓 / 抛公海 / 关闭 / 移交 / 转项目等流转。与线索同构,依赖方向单向 `crm-opportunity → crm-rule → (crm-auth / crm-dict / crm-file)`;商机规则(阶段模板 / 方案卡模板 / 方案推送 / 项目查重 / 商机公海与提醒 / 商机字典,原型 A7-3-2)归 `crm-rule`,本模块只消费。商机由**线索转入**或**销售直接创建**两条入口进入,进系统后完全平权(不按来源切片状态机/公海/视图)。
销售机会的全生命周期业务域:商机实体、两条正交轴(归属流转**状态** + 业务**阶段**)、四视图(商机公海 / 销售机会(我负责的)/ 我的关注 / 商机管理)、领取 / 分配 / 暂缓 / 抛公海 / 关闭 / 移交 / 转项目等流转。与线索同构,依赖方向单向 `crm-opportunity → crm-rule → (crm-auth / crm-dict / crm-file)`;商机规则(阶段模板 / 方案卡模板 / 方案推送 / 项目查重 / 商机公海与提醒 / 商机字典,原型 A7-3-2)归 `crm-rule`,本模块只消费。商机由**线索转入**或**销售直接创建**两条入口进入,进系统后完全平权(不按来源切片状态机/公海/视图);两条入口的建档机制收敛于深模块**商机建档**(OpportunityIntake,ADR-0030)
> 本文档只钉词汇与边界(票 01);状态机迁移图、表结构、公海规则、四视图查询各留给后续票(04/03/05/10)。事实出处见 `.scratch/opportunity-module/research-close-pause-actions.md`(原型活数据 V1.0-20260815)。
@ -42,6 +42,14 @@ _Avoid_: 来源线索、渠道(口语,字段名 oppSource)、来路
线索转入的血缘键 `source_lead_id`(可空 FK)。**两条入口都可写此字段**:线索侧 port 自动带(票 02),商机侧新增入口当用户选商机来源=「线索转入」时手选一条【关联线索】(票 12,候选范围 = 线索状态已领取/跟进中,与线索侧转商机准入对称)。直接创建且商机来源非「线索转入」的为 NULL(「直接创建」= `source_lead_id IS NULL`,不单列术语)。是幂等键——`UNIQUE(source_lead_id)`(票 02)保证**一条线索只能关联一条商机**,两条入口共享同一 DB 约束。**不参与商机身份**:线索转来的与直接创建的商机在模型里完全平权。
_Avoid_: 商机来源(那是 oppSource 渠道)、线索 id、parent
**商机建档**:
两条创建入口收敛出的深模块 `com.crm.opportunity.intake.OpportunityIntake`(单方法 `open(spec)`,ADR-0030):主表落库 + 阶段落位 + 主要意向客户子表 + 初始操作日志 + 领取人团队成员五步机制 + `source_lead_id` 唯一性防护(预检 + 并发唯一键转译),一个事务内原子完成(REQUIRED 传播——线索侧并入 crm-lead 外层事务,ADR-0020 语义不变)。入参是内部统一契约 `OpportunityIntakeSpec`(不导出);失败抛自有 `OpportunityIntakeException`,**不跨 seam**:线索侧 port 译为 `OpportunityCreationException`,商机侧直接新建译为 `BusinessErrorException`。两条入口(转商机 port / 直接新建 service)自此只做「入参→spec 翻译 + 异常翻译」,机制改动只动一处。
_Avoid_: 建商机(口语)、创建商机 service(那是 adapter,不是机制)、IntakeService(类型名已定死 OpportunityIntake)
**进入方式**:
建档来源枚举 `IntakeSource`(`LEAD_CONVERT` 线索转入 / `DIRECT` 直接新建),承载两条入口按来源分叉的全部细节:初始操作日志的 `op_source`(SYSTEM/USER)与 `op_desc`(「由线索转入创建」/「直接创建商机」),以及 LEAD_CONVERT 恒定的商机来源字典码 `defaultOppSource()`=opp_source_01(修正转商机路径漏填 oppSource 的缺陷;DIRECT 为 null,由用户所选经 spec 显式携带)。
_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。
_Avoid_: 客户(客户是 A4 域的实体)、甲方(甲方是快照里的一个字段)、customer

44
docs/adr/0030-opportunity-intake-deep-module.md

@ -0,0 +1,44 @@
---
status: accepted
---
# 商机建档深模块:OpportunityIntake(收敛两条创建入口 + 修正转商机漏填 opp_source)
## Context
架构审查(`/improve-codebase-architecture`,2026-08-25,候选 #1):商机建档有两条入口——
1. 线索侧转商机 port `OpportunityCreationPortImpl`(crm-lead 出站契约 `OpportunityCreationPort` 的实现,票 11/12/13/16);
2. 商机侧直接新建 `OpportunityCreateServiceImpl`(票 12)。
两者逐字复制了五步建档机制(主表落库 + `applyInitialStage` 阶段落位 + `linkPrimaryCustomer` 客户子表 + `writeInitialOplog` 初始日志 + `insertOwnerTeamMember` 团队成员),外加 `source_lead_id` 唯一性预检与 `DuplicateKeyException` 转译,仅初始日志文案/op_source 与 oppSource 取值有细微分叉(`PROJECT_ROLE_OWNER`/`ROLE_INTENDED` 等常量也是两份)。复制结构已产出一个真实缺陷:**线索转商机路径从未给主表设置 `oppSource`**——产品口径「从线索转来的商机,oppSource 默认置『线索转入』」(CONTEXT.md「商机来源」)自始未在 port 侧成立,两条入口行为漂移。
## Considered Options
(grilled consensus 2026-08-25,详见报告卡与 handoff)
- **深模块吃哪个入参**:新内部契约 `OpportunityIntakeSpec`(选中——两条入口字段并集,crm-opportunity 私有)/复用 `CreateOpportunityCmd`(那是 crm-lead 的出站契约,直接新建被迫背着别域 DTO)/复用 `CreateOpportunityRequest`(HTTP DTO 漏进领域内部)。
- **事务位置**:`@Transactional` 落 `open()`,adapter 全部去掉(选中——线索侧并入 crm-lead 外层事务靠 REQUIRED 传播,ADR-0020 语义不变)/adapter 各自保留 `@Transactional`(事务边界留在两个调用点,深模块原子性依赖调用方纪律)。
- **异常形状**:自有 `OpportunityIntakeException` 不跨 seam、adapter 在边界翻译(选中)/深模块直接抛 `BusinessErrorException`(HTTP 语义漏进模块内部)/深模块抛 crm-lead 契约异常(反向依赖出站契约)。
- **差异承载**:`IntakeSource` 枚举承载初始日志 `op_source`/`op_desc` + LEAD_CONVERT 恒定 `opp_source_01`(选中)/spec 布尔标志(`isLeadConvert` 散落判断)/子类多态(两分支而已,过度设计)。
## Decision
新建 `com.crm.opportunity.intake` 包:
- `OpportunityIntake`:接口,单方法 `Long open(OpportunityIntakeSpec)`
- `OpportunityIntakeSpec`:record,内部统一契约(不导出);
- `IntakeSource`:枚举 `LEAD_CONVERT`/`DIRECT`,承载初始日志 `op_source`(SYSTEM/USER)、`op_desc`(由线索转入创建/直接创建商机)与 LEAD_CONVERT 恒定商机来源码(`defaultOppSource()`=opp_source_01,DIRECT 为 null、由 spec 显式携带用户所选);
- `OpportunityIntakeException`:自有领域异常,不跨模块边界;
- `impl/OpportunityIntakeImpl`:`@Service`,`open()` 标 `@Transactional(rollbackFor=Exception.class)`,独占五步建档机制 + 唯一性预检(sourceLeadId 非空才检,DIRECT NULL 不参与 UNIQUE)+ `DuplicateKeyException`→领域异常转译;`PROJECT_ROLE_OWNER`/`ROLE_INTENDED` 常量随之收敛为私有。
两个 adapter 瘦成纯翻译层:port 侧 `cmd→spec`(source=LEAD_CONVERT,oppSource=枚举默认)+ `OpportunityIntakeException→OpportunityCreationException`;直接新建侧 `request→spec`(source=DIRECT,oppSource 用户所选,`ownerDeptId=null` 走默认模板兜底、与收敛前行为一致)+ `OpportunityIntakeException→BusinessErrorException`,并保留入口特有的 sourceLeadId 必填校验与 `listConvertibleLeads`(后者属候选 #2 范围,不动)。
## Consequences
- 机制代码两处(180 + 195 行)收敛一处(intake impl 180 行);adapter 只剩翻译(port 59 行、service 99 行,service 因保留候选线索查询稍厚)。port 的依赖从 5 个 bean 降到 1 个。
- **BUG 修正随迁**:线索转商机路径 `oppSource` 恒落 `opp_source_01`(守护测试 `open_leadConvert_setsOppSource`),产品口径自此在代码成立。
- 测试 replace 不 layer(ADR-0022/0026 先例):机制断言上移 `OpportunityIntakeImplTest`(12 例,两来源分支全覆盖),adapter 测试只留翻译断言(port 2 例 + service 4 例);删机制用例不降覆盖。
- 术语沉淀:`crm-opportunity/CONTEXT.md` 新增「商机建档」「进入方式」。
- 候选 #2(`ILeadService` 泄漏 / `ConvertibleLeadCatalogPort`)、#3(DisplayNameEnricher)、#4(LeadOpportunityConverter)、#5(oplog recorder)不在本票范围;将来若抽 oplog recorder,`writeInitialOplog` 是第一候选。
- 决策来源:`/improve-codebase-architecture` 审查(2026-08-25,候选 #1)+ grilled consensus;实现接力见 handoff `opportunity-intake-20260825`
Loading…
Cancel
Save