From 4b1ec8c9b32d8135dfd5ca4b96366deac61a59f6 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 21:34:52 +0800 Subject: [PATCH] =?UTF-8?q?docs(opportunity):=20ADR-0030=20=E5=95=86?= =?UTF-8?q?=E6=9C=BA=E5=BB=BA=E6=A1=A3=E6=B7=B1=E6=A8=A1=E5=9D=97=20+=20CO?= =?UTF-8?q?NTEXT.md=20=E6=9C=AF=E8=AF=AD=E5=90=8C=E6=AD=A5=EF=BC=88Candida?= =?UTF-8?q?te=20#1=20=E6=94=B6=E5=AE=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-0030:OpportunityIntake 收敛两条建档入口的决策记录 (spec 内部契约 / 事务落 open / 异常不跨 seam / IntakeSource 承载差异) 含转商机路径漏填 oppSource 的 BUG 修正记录 - CONTEXT.md 新增术语「商机建档」(OpportunityIntake)「进入方式」(IntakeSource), 首段补深模块指引 --- crm-opportunity/CONTEXT.md | 10 ++++- .../0030-opportunity-intake-deep-module.md | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0030-opportunity-intake-deep-module.md diff --git a/crm-opportunity/CONTEXT.md b/crm-opportunity/CONTEXT.md index 4278892..a7dda4c 100644 --- a/crm-opportunity/CONTEXT.md +++ b/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 diff --git a/docs/adr/0030-opportunity-intake-deep-module.md b/docs/adr/0030-opportunity-intake-deep-module.md new file mode 100644 index 0000000..d9da09a --- /dev/null +++ b/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`。