# 客户 ↔ 商机硬依赖全景(票 03 附件) > 全部结论以 2026-09-11 HEAD(dd6fa42)源码为准;grep/读码实证,未跑 mvn。 > 模块方向:Maven 单向 `opportunity → customer`(ADR-0029),跨域交互全部走「接口定义在 crm-customer、crm-opportunity 出 `@Primary` 实现」的 port seam。 ## 1. 客户详情「关联商机」页签 ### 1.1 端点与链路 - 端点:`GET /api/customer/opportunity/page?id={customerId}[¤t=&size=]` (`CustomerDetailController.pageOpportunities`,tag `A4 客户管理/客户详情/关联商机`;BaseParam 分页) - 链路:`CustomerDetailServiceImpl.pageOpportunities`(仅 require 客户存在 67002) → 出站 port `crm-customer/.../port/CustomerOpportunityQueryPort.pageByCustomerId` → crm-opportunity `port/inbound/impl/CustomerOpportunityQueryPortImpl`(`@Primary` 接管 `NoopCustomerOpportunityQueryPortImpl` 兜底,Noop 不删)。 ### 1.2 SQL 真源(`OpportunityMapper.pageByCustomer`) ```sql SELECT o.* FROM opportunity_customer oc JOIN opportunity o ON o.id = oc.opportunity_id WHERE oc.customer_id = #{customerId} AND oc.delete_key = 0 AND o.deleted = 0 ORDER BY o.update_time DESC, o.id DESC ``` - 关联真源 = `opportunity_customer` 多对多子表(软删复用键 delete_key;UNIQUE(opportunity_id, customer_id, delete_key))。**客户侧表没有任何商机外键**。 - 阶段列不在 SQL join:实现层把本页 `current_stage_id` 集合交 crm-rule `describeStages` 批量解析(customNodeName 优先、空则 opp_stage 字典名)。 ### 1.3 出参实际列集(`record OpportunityItem`,恒 7 字段) | # | 字段 | 类型 | 物理来源 | 说明 | |---|---|---|---|---| | 1 | id | Long | opportunity.id | 商机ID | | 2 | oppName | String | opportunity.opp_name | 商机名称 | | 3 | stageName | String | crm-rule describeStages(current_stage_id) | 当前工作节点展示名(解析失败为 null → 前端显「--」) | | 4 | estimateAmount | BigDecimal | opportunity.project_amount | 预计金额(=主表「项目金额」列换名出参) | | 5 | ownerUserId | Long | opportunity.owner_user_id | 销售负责人ID | | 6 | ownerNameSnapshot | String | opportunity.owner_name_snapshot | 负责人姓名快照 | | 7 | updateTime | LocalDateTime | opportunity.update_time | 最近更新时间 | ### 1.4 G2「7 vs 13 列」契约漂移定性 - 原型 13 列(matrix-a4-4 §4.1):商机名称/项目/区域/招标形式/阶段/预计金额/下次跟进/甲方/意向客户/负责人/更新时间…;实现恒返上表 7 字段。 - 缺列分两类:①项目/区域/招标形式依赖 A5 与商机主表扩展;②下次跟进时间/甲方/意向客户需 opportunity 侧 port 增列——**挂账跨模块票**(issues-07 §C 定稿已收窄口径,不阻塞验收)。 - **Demo 契约事实 = 7 字段**,缺列显「--」,不得假设后端会补。 ### 1.5 既有 demo(`.scratch/customer-e2e/demo/index.html` L1104-1108)字段漂移 调用端点正确,但画 5 列且字段名错 3 处: | demo 读的字段 | 实际字段 | 现象 | |---|---|---| | `o.opportunityName \|\| o.name` | `oppName` | 都不命中 → 回退显 `o.id` | | `o.ownerUserName \|\| o.ownerName` | `ownerNameSnapshot` | 恒「--」 | | `o.statusName \|\| o.status` | (无此字段,出参无状态列) | 恒「--」 | | `o.stageName` / `o.estimateAmount` | 同名 | 正确 | 新 demo 直接按 1.3 的 7 个真名渲染即可。 ### 1.6 同 port 的守卫方法( Demo 阻断现象的机制) `hasActiveOpportunity(customerId)` = `OpportunityMapper.countActiveByCustomer`: `opp_status IN (1,2,3)`(1 待领取 / 2 推进中 / 3 暂缓中)计数 > 0。已关闭 4 / 已转项目 5 视为终结,**不阻断**。 ## 2. 「新增/编辑客户挂商机」——票面前提修正 1. **customer 侧没有「挂商机」字段与端点**:`Customer` 实体、`CustomerCreateDTO`、`CustomerUpdateDTO` grep `opp|opportunity` 均 0 命中;`POST /api/customer/create` / `POST /api/customer/edit?id=` 字段面与商机无关(edit 且「归属字段不可改」,API-SUMMARY §2.3)。客户↔商机关联唯一写入口在商机侧(`opportunity_customer` 子表)。 2. **ticket01 的 opp_id 可空化与客户域无关**:两次提交(9559b25、bc76b0c 修正补充)动的是 `opportunity_scheme_card.opp_id`(方案卡「历史所属商机ID」遗留列,ADR-0041 方案卡 owner 化解耦后实体不再映射)。语义: - 迁移器 `SchemeCardOwnerMigrationRunner`(CommandLineRunner)启动时执行 `ALTER TABLE opportunity_scheme_card MODIFY COLUMN opp_id bigint NULL COMMENT '(已废弃·owner 化解耦遗留)…'`; - bc76b0c 后**仅在 `IS_NULLABLE=NO` 时执行**(查 DatabaseMetaData,幂等——共享库每次启动不再重复 ALTER); - 动机:实体不映射该列,NOT NULL 会让 A3 新建方案卡首次 INSERT 即失败;旧行值保留供审计,新行恒 NULL。 3. 结论:Demo 不做「新增/编辑客户挂商机」表单;演示关联动作落商机侧(§4 两条入口)。 ## 3. 客户归属变更 × 商机级联矩阵 | 动作 | 端点 | 商机守卫 | 商机数据级联 | Demo 可演示现象 | |---|---|---|---|---| | 领取 claim | `POST /api/customer/claim?id=`(批量 `-batch`) | **有**:`guardNoActiveOpportunity` → 67007 | 无 | 有进行中商机时领取被 67007 拒(「客户存在进行中商机,不可抛入公海」文案族,动作=领取) | | 分配/换负责人 assign | `POST /api/customer/assign?id=&userId=`(批量 `-batch`) | 无 | **无(不对称点)** | 只改客户 owner 四列快照 + 客户 oplog TRANSFER;名下商机 owner 原地不动 → 客户负责人与商机负责人可不一致 | | 抛公海 releasePool | `POST /api/customer/release-pool?id=`(批量 `-batch`) | **有**:67007 | 无(放行时仅清客户 owner+协同人) | 有进行中商机 → 67007 弹错;商机全部终结(4/5)→ 放行,存量终结商机不动 | | 归档 archive | `POST /api/customer/archive?id=` / `-batch`(整批预检) | **有**:67006(另:仅负责人可归档 67004) | 无 | 同上;归档后 `archiveStatus=2`,从此商机详情候选池排除该客户(§4) | | 恢复 restore | `POST /api/customer/restore?id=` | 无 | 无 | 仅负责人 + 已归档才可(67004) | | 交割发起 initiate | `POST /api/customer/transfer/initiate`(body 表单 reason[,toDirectorId]) | 无守卫(不需要) | **强级联·整批原子** | 逐客户 `changeOwner→接收总监` + 商机 owner 同步 + 项目 owner 同步;任一失败全部回滚、不生成交接单;防重入 67010 | | 交割分配 assignOne | `POST /api/customer/transfer/assign?id=&customerIds=&assignUserId=` | 无 | **强级联·逐条独立事务** | 被分配客户的商机 owner 三列刷成新销售;部分成功列原因;全部分配完 D23 自动置交接单已完成 | ### 3.1 商机 owner 同步实现(`OpportunityOwnerSyncPortImpl`) - port:`crm-customer/.../port/OpportunityOwnerSyncPort`(`syncOwnerByCustomer` / `syncOwnerByCustomers`),实现在 crm-opportunity(`@Primary` 接管 Noop 兜底)。消费方仅交割两段(`CustomerTransferServiceImpl` L173/L242)——**claim/assign/releasePool/archive 均不调用**。 - 行为:经 `opportunity_customer`(delete_key=0)反查 oppIds → `update(null, wrapper)` 刷 `owner_user_id / owner_name_snapshot / owner_dept_id` 三列;接收人不存在抛业务异常回滚调用方事务;**不 bump @Version**(派生同步不与商机并发编辑 CAS 互扰);`owner_dept_id` 是商机 DataScope 锚点——「移交跟人走」口径。 - **留痕不对称**:项目侧 `ProjectOwnerSyncPortImpl` 同步外还逐项目写 ACTION_TRANSFER 动态(近期提交 c9d6e75,评审 P3-6);商机侧同步**不写商机 oplog**。Demo 若要留痕证据,看客户 oplog(TRANSFER 系统文案带交接单号 JG…),别等商机动态。 ### 3.2 Demo 级联闭环建议(一条链演示全部现象) 建商机(带 customerId)→ 客户详情「关联商机」页签出现该商机(阶段=首节点展示名)→ 抛公海被 67007 阻断 → 商机置已关闭(4) 后抛公海放行 → 交割发起+分配 → 商机 owner/部门快照跟随变化 + 客户 oplog TRANSFER 留痕。 ## 4. 反向:商机创建对客户的依赖 - **弱依赖(customerId 可空)**:`POST /api/opportunity`(`OpportunityCreateController`,tag `A3 商机管理/新建商机`)。硬必填仅 6 项:商机名称/商机来源/招标形式/省/市/项目属地(D-14)。`customerId` 非空时联动必填:`isPrimaryIntended`(仅 0/1)+ `customerRole`(66xxx 参数族)。 - **两条建关联行的路径校验强度不同**: - 直接新建:`OpportunityIntakeImpl.linkPrimaryCustomer` **不做客户存在性/归档校验**(customerNameSnapshot 原样取前端传值落库;isPrimaryIntended 缺省按 1、customerRole 缺省回退主要意向客户角色); - 商机详情挂客户:`POST /api/opportunity/customer/add`(`OpportunityCustomerServiceImpl.addCustomer`,表单 `oppId/customerId/customerRole/isPrimaryIntended`)走 `CustomerCatalogPort.requireLive` **硬校验**——不存在/已归档客户拒关联;重复关联友好化报错;设主要自动降旧主要(同事务)。 - **归档客户与存量关联**:归档不清理 `opportunity_customer` 存活行 → 客户归档后其「关联商机」页签仍能反查出商机;但商机详情侧不可再新挂该客户、候选池搜索也不出。 - **客户侧无「从客户建商机」端点**:crm-customer 的 ports 全是 query/sync/catalog 方向。API-SUMMARY §2.4「商机弹窗快建」是**反向**(商机表单里快建客户:`POST /api/customer/quick-create` + `POST /api/customer/search`)。 - **Demo「从客户发起建商机」**:无专用端点也不需要新后端——用 `POST /api/opportunity` 预填 customerId(`customerName` 一并带出作快照)即可;落库后页签立即可见。建议提供该入口:一条链路同时演示双向关联 + 后续级联。 ## 5. 端点 × 字段 × 触发场景速查 | # | 端点 | 关键入参 | 触发场景 | 对商机域的效果 | |---|---|---|---|---| | 1 | `GET /api/customer/opportunity/page?id=` | id, current, size | 客户详情页签 4 | 只读反查 7 字段,零写副作用 | | 2 | `POST /api/customer/claim?id=` | id | 公海领取 | 有进行中商机 → 67007 阻断 | | 3 | `POST /api/customer/assign?id=&userId=` | id, userId | 换负责人 | 不级联商机(不对称点) | | 4 | `POST /api/customer/release-pool?id=` | id | 抛公海 | 有进行中商机 → 67007 阻断 | | 5 | `POST /api/customer/archive?id=`(`-batch` 整批预检) | id / ids | 归档 | 有进行中商机 → 67006 阻断 | | 6 | `POST /api/customer/transfer/initiate` | reason[, toDirectorId] | 交割第一段 | 整批原子:客户+商机+项目 owner 同刷接收总监 | | 7 | `POST /api/customer/transfer/assign?id=&customerIds=&assignUserId=` | id, customerIds, assignUserId | 交割第二段 | 逐条独立事务:商机 owner 跟随新销售;D23 自动完成 | | 8 | `POST /api/opportunity` | customerId?, customerRole?, isPrimaryIntended?(customerId 非空时后两者必填) | 从客户发起建商机(demo 合成入口) | 建 opportunity_customer 行 → 页签可见;无 requireLive 校验 | | 9 | `POST /api/opportunity/customer/add` | oppId, customerId, customerRole, isPrimaryIntended | 商机详情挂客户 | requireLive 硬校验(归档拒)+ 去重 + 设主降旧主 | 关联错误码:67004 状态不允许 / 67006 归档阻断 / 67007 抛公海阻断(claim 亦复用)/ 67010 交割防重入 / 67011 交割前置不合法 / 67012 分配对象非法 / 67002 客户不存在或已删除。