You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

143 lines
8.7 KiB

4 weeks ago
# 转商机 outbound port 契约(crm-lead → 商机模块)
Type: grilling
Status: resolved
Blocked by: 06
<!-- 03 主体已定稿(转商机前置=已领取/跟进中 B2、后继=已转商机终态已确定),作废悬置不影响转商机(作废态本就不可转),故不再阻塞 -->
## Question
商机模块尚不存在(见 ticket 01)。转商机不能对接现成服务,只能定义 **crm-lead 向未来商机模块期望的 outbound port 契约**(定期望、非对接实现)。
需在本 ticket 定清:
1. **触发条件**:仅"跟进中/有效"状态线索可转(对齐 ticket 03 状态矩阵);已转商机线索不可重复转。
2. **入参契约**:转商机弹窗字段——商机名称(必填)、行业(必填,字典 industry)、甲方(可选)、意向客户(必填)、地区(必填)、备注(可选),加上从线索带出的字段(线索 id、客户信息、需求产品、领取人/部门)。
3. **port 接口形态**:crm-lead 定义一个 `OpportunityCreationPort` 接口(入参 DTO + 返回商机 id),实现由商机模块将来提供;未实现时的降级/占位策略(spec 里标注"待商机模块落地")。
4. **线索侧副作用**:线索状态 → 已转商机;写一条"转商机"历史记录(含生成的商机名称/id);线索是否仍保留在我的线索列表(原型:已转商机线索仍在列表,仅隐藏释放按钮)。
5. **失败/回滚语义**:商机创建失败时线索状态是否回滚;幂等(防重复转)。
6. **依赖 06** 的线索字段模型确定入参映射;**依赖 03** 的状态矩阵确定触发条件与副作用。
产出:outbound port 接口签名 + 入参 DTO + 副作用与失败语义,写入 ## Answer。属 crm-lead spec 的"对外依赖契约"章节。
## Decisions(grill 定稿)
- **[Q1=a]** 触发条件 = 仅看状态:`status IN (已领取, 跟进中)`。反馈情况完全不参与判定。**推翻原型**"跟进中/已联系/有效"+"禁止:无效/未反馈"口径(产品口径改)。
- **[Q2 弹窗字段]** 6 字段数据源:
- 商机名称 = 纯文本
- 行业 = 字典 code `opportunity.industry`(商机模块将来创建字典分组,crm-lead spec 仅声明依赖)
- 甲方 = 纯文本(无客户模块;前端 UX 从线索历史 lead_name 建议候选,最终存文本)
- 意向客户 = 纯文本(同上,无客户模块)
- 地区 = `region_code varchar(12)` 存国标 code;支持前缀 LIKE 祖先查询(见 02 Amendment)
- 备注 = 纯文本
- **[Q3=a]** port 形态 = **同步接口** `OpportunityCreationPort#createOpportunity(cmd) → opportunityId`;crm-lead 事务内调用;商机模块未落地时 spec 里注明"接口签名待商机模块实现",运行期无 bean 时报明确错误。
- **[Q4=a]** 失败/回滚 = **本地事务性回滚**(`@Transactional` 包住"改状态+调 port+写历史",port 抛异常整个事务回滚,线索状态不变)。假设商机模块与 crm-lead 同进程/同库。
- **[Q5=a+c]** 幂等 = 双保险:
- crm-lead 侧:前置校验 `status IN (已领取, 跟进中)`,已转商机是终态(03 定),重复点第二次天然被拦下(方案 a)
- 商机侧:port 契约要求商机表加 `UNIQUE(source_lead_id)`,重复插入自然失败(方案 c)
## Answer
### 一、outbound port 接口签名(crm-lead 定义,商机模块实现)
```java
package com.crm.lead.port.outbound;
/** crm-lead 声明的 outbound port,实现由商机模块提供 */
public interface OpportunityCreationPort {
/**
* 由线索创建商机。
* 同步调用,crm-lead 事务内触发;失败抛异常整个转商机流程回滚。
* 商机侧须保证 UNIQUE(source_lead_id) 幂等语义。
*
* @return 新建商机 id(用于写入 lead_history 详情)
* @throws OpportunityCreationException 商机侧任何失败均抛此异常
*/
Long createOpportunity(CreateOpportunityCmd cmd);
}
```
### 二、入参 DTO
```java
public record CreateOpportunityCmd(
// 从弹窗表单
Long sourceLeadId, // 来源线索 id(幂等 key)
String opportunityName, // 商机名称 * 必填
String industryCode, // 行业字典 code (opportunity.industry) * 必填
String partyA, // 甲方 (自由文本, 可空)
String intendedCustomer, // 意向客户 (自由文本) * 必填
String regionCode, // 地区国标 code (varchar 12) * 必填
String remark, // 备注 (自由文本, 可空)
// 从线索带出(crm-lead 服务内组装, 不由前端上送)
String sourceLeadName, // 线索名称快照
String sourcePhone, // 联系电话快照
String sourceProductCode, // 需求产品 code 快照
Long ownerUserId, // 领取人 = 转商机操作人
Long ownerDeptId, // 领取人所属部门快照
Long poolId // 来源公海池 id
) {}
```
### 三、副作用(crm-lead 侧, 本地事务内)
```
BEGIN TX
1. 前置校验:
- lead.status IN (已领取, 跟进中) 否则抛 IllegalStateException("状态不允许转商机")
- 当前用户 = lead.owner_user_id 否则抛权限异常(只有领取人自己能转)
2. 调 port.createOpportunity(cmd) 拿 opportunityId
3. UPDATE lead SET status = '已转商机' WHERE id = ? AND status IN ('已领取','跟进中')
- 行数 = 0 抛并发异常(乐观锁)
4. INSERT lead_history (lead_id, op_type='CONVERT', op_time=now, op_user_id, detail)
- detail 含 { opportunityId, opportunityName }
5. 冻结时效计时器(03 定稿:转商机后 recycle_deadline / expire_deadline 停止判定)
- 实现:定时任务扫描时 WHERE status != '已转商机',无需 UPDATE
COMMIT
```
### 四、后置约束(对齐 03 定稿)
- 已转商机 = 终态,锁死所有编辑:不再支持反馈、释放、编辑。
- 已转商机线索**仍在"我的线索"列表可见**,操作列隐藏【释放】【转商机】【反馈】【编辑】按钮,仅保留【详情】【取消关注】(若已关注)。
- 时效计时器冻结(不再回收、不再失效)。
### 五、失败语义
| 失败点 | 处理 |
|---|---|
| 前置校验(状态/权限)| 直接拒绝,无副作用 |
| port 调用异常 | 整个事务回滚,线索状态不变,无历史日志 |
| port 成功但后续 UPDATE 失败 | 事务回滚。**注意**:商机侧因 `UNIQUE(source_lead_id)` 已插入的记录会被 crm-lead 本地事务回滚**看不到**——因为若 port 是本地 bean 走同一 DataSource 事务则完全回滚;若 port 跨库/跨进程则需商机侧提供补偿接口(当前假设同库同事务,spec 只留 TODO) |
### 六、幂等保证
- **crm-lead 侧**:状态终态保护 + 乐观锁 WHERE 状态条件(第 3 步)。第二次转会因 WHERE 找不到行而抛异常。
- **商机侧**:port 契约要求商机表 `UNIQUE(source_lead_id)`,即使 crm-lead 侧防线被绕过,商机侧也保证一线索最多一商机。
### 七、原型待 UX 修订
产品口径改(已领取 + 跟进中都能转),原型 3 处文案作废:
- `a2-1-2____.txt:1826`:`4.转商机:仅有效 / 跟进中 / 已联系线索可见` → 改为 `仅已领取 / 跟进中线索可见`
- `a2-1-2____.txt:1898`:`状态为跟进中 / 已联系 / 有效的线索可点击【转商机】按钮` → 改为 `状态为已领取 / 跟进中的线索可点击【转商机】按钮`
- `a2-1-2____.txt:1929-1930`
- `1.可打开弹窗的线索状态:跟进中、已联系、有效` → 改为 `可打开弹窗的线索状态:已领取、跟进中`
- `2.禁止转商机场景:已转商机、过期失效、无效、未反馈` → 改为 `禁止转商机场景:待领取、未分发、过期失效、线索作废、已转商机(反馈情况不参与判定)`
### 八、依赖声明
- **依赖既存**:03(状态触发/终态)、06(线索快照字段)、02 Amendment(sys_region.code)
- **依赖未来**:商机模块(`OpportunityCreationPort` 实现方、`opportunity.industry` 字典分组、`opportunity` 表 `UNIQUE(source_lead_id)`
- **crm-lead spec 交付时**:`OpportunityCreationPort` 接口 + `CreateOpportunityCmd` DTO + 异常类型齐全;实现类不写,商机模块落地时 Spring 扫描注入即可
### 九、CONTEXT 登记
- `crm-lead/CONTEXT.md`:术语补"转商机 = 单向不可逆终态操作,产出商机 id,写 CONVERT 历史"
- `CONTEXT-MAP.md` 无需改动(08 属 crm-lead 出站契约,不新增模块)
## Status
本 ticket 已定稿可交付 → resolved。