Browse Source

feat(opportunity): 票 03 商机主表落地(Opportunity 实体 + 5 态枚举 + Mapper 骨架)

TDD 三切片(红→绿各一轮,19 测试全绿):

- OpportunityStatus:轴 1 归属流转 5 态枚举(1待领取/2推进中/3暂缓中/4已关闭/5已转项目),
  implements HasValueEnum<Integer>;只钉值域,迁移逻辑归票 04
- Opportunity:主表实体(票 03 三簇 + Amendment 1),同构 crm-lead/Lead 写法
  * 簇 A 业务字段 12 列(含 project_amount decimal(14,2) 选填,Amendment 1)
  * 簇 B 归属三层(owner_user_id/owner_dept_id/origin_dept_id)+ 两轴落点
    (opp_status/current_stage_id/stage_template_id+版本号冗余)+ 来源快照 4 列
    + 单计时器 claim_time/last_valid_follow_time
  * C3 主客户冗余快照 2 列、暂缓附加 4 列落主表(E4 写/E5 清空)
  * 乐观锁 @Version version=0(切主客户票 13 / 状态 CAS 票 04 复用,接线归后续票)
  * @DataScope(module=opportunity, owner_user_id, owner_dept_id)——module 已在
    crm-auth DataInitializer 预注册,不触发 ADR-0006 启动拒绝
  * 索引:UNIQUE(source_lead_id) + (owner_user_id,opp_status) + (owner_dept_id,opp_status)
  * 不建:pool_id/dept_id/recycle_deadline/expected_deal_date(票 05/10 定稿排除)
- OpportunityMapper:骨架 extends CrmBaseMapper(同构 LeadMapper)
- 模块装配:根 pom modules/dependencyManagement + crm-app 聚合依赖
- CONTEXT.md 待细化小节勾销主表项

边界守住:子表归 07/08/09,customerId 契约归 11,关联客户表归 13,均未触碰。
验证:全量 BOM 扫描 0 命中;全反应堆 clean test 9 模块 SUCCESS。
master
luoweijian 2 weeks ago
parent
commit
ecc3c49636
  1. 4
      crm-app/pom.xml
  2. 34
      crm-opportunity/CONTEXT.md
  3. 58
      crm-opportunity/pom.xml
  4. 203
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java
  5. 35
      crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OpportunityStatus.java
  6. 14
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityMapper.java
  7. 193
      crm-opportunity/src/test/java/com/crm/opportunity/domain/entity/OpportunityTest.java
  8. 53
      crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/OpportunityStatusTest.java
  9. 56
      crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityMapperTest.java
  10. 6
      pom.xml

4
crm-app/pom.xml

@ -45,6 +45,10 @@
<groupId>com.crm</groupId> <groupId>com.crm</groupId>
<artifactId>crm-lead</artifactId> <artifactId>crm-lead</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-opportunity</artifactId>
</dependency>
<!-- 运行时依赖:数据库驱动 --> <!-- 运行时依赖:数据库驱动 -->
<dependency> <dependency>

34
crm-opportunity/CONTEXT.md

@ -27,9 +27,13 @@ _Avoid_: 负责人(负责人是公海池概念)、持有者、owner(英文
_Avoid_: 录入人、owner(owner 是领取人) _Avoid_: 录入人、owner(owner 是领取人)
**团队成员**: **团队成员**:
领取人拉入协作的人员,存于独立关联(原型「添加团队成员&一键拉群」A3-1-1-2-8、团队成员 Tab A3-1-1-1-8)。每人配**项目角色**(商机负责人 / 方案工程师 / 招标专员 / 现场工程师 等)与**权限**(读写 / 只读 / 可写跟进 / 可推进节点 等)。团队成员是**行级授权白名单**——绕开 DataScope 部门天花板给出可见性,但能否写取决于其自身权限点。**生效边界(grill 补拍 20260821)**:详情/子表读 + 个人工作区视图(我参与的/recent);管理/公海工作区严格按 DataScope 不混白名单;详情可见 = DataScope OR 白名单命中。**抛公海只清空领取人,不动团队成员**(产品明确)。**转商机时自动插一条团队成员:领取人 = 商机负责人(票 02 拍板)**。 领取人拉入协作的人员,存于独立关联(原型「添加团队成员&一键拉群」A3-1-1-2-8、团队成员 Tab A3-1-1-1-8)。每人配**项目角色**(商机负责人 / 方案工程师 / 招标专员 / 现场工程师 等,**纯回显标签、不带任何读写权限**,grill 拍板 20260821)与**权限**(读写 / 只读 / 可写跟进 / 可推进节点 等,**与角色无关、单独配**,原型「改角色」「改权限」两个独立入口)。团队成员是**行级授权白名单**——绕开 DataScope 部门天花板给出可见性,但能否写取决于其自身权限点。**生效边界(grill 补拍 20260821)**:详情/子表读 + 个人工作区视图(我参与的/recent);管理/公海工作区严格按 DataScope 不混白名单;详情可见 = DataScope OR 白名单命中(仅存活行 `deleted=0`;移除成员=软删 team 行,所有成员身份判定带 `deleted=0`,grill 拍板 20260821)。**抛公海只清空领取人,不动团队成员**(产品明确)。**转商机时自动插一条团队成员:领取人 = 商机负责人(票 02 拍板)**。
_Avoid_: 协作人、参与者、观察者(团队成员带角色与权限,不只是「能看」) _Avoid_: 协作人、参与者、观察者(团队成员带角色与权限,不只是「能看」)
**子表软删复用键**:
商机带唯一约束子表的删除通则(grill 拍板 20260821,对齐 `crm-rule` `LeadPool` 既有实践):带业务字段/需审计的子表(`opportunity_customer`/`opportunity_scheme_card`/`opportunity_team`)走**软删除 + 唯一索引复用键 `delete_key`**——存活 `delete_key=0`,逻辑删除时 `delete_key=id`,唯一索引挂 `(业务键列, delete_key)` 而非裸列(避免逻辑删除行占唯一位、同键重新添加报冲突)。纯行为/收藏表(`opportunity_focus`/`opportunity_view_log`)无业务字段走**物理硬删**,唯一索引裸列即可。
_Avoid_: 裸列唯一索引+逻辑删除(会因旧删除行占位报重)、关联表一律硬删(带审计的子表软删保留历史);focus/view_log 不软删(纯行为流水,硬删 + logback INFO 排查,不入 oplog;grill 20260821 选项 C)
**商机来源**: **商机来源**:
渠道归因字段 `oppSource`(字典码,必填,原型 A3-1-1-2-1)。表达商机从哪个渠道来(展会 / 转介绍 / 官网 等)。**与「来源线索」是两回事**——来源是渠道分类,每条商机都有;来源线索是血缘。**从线索转来的商机,`oppSource` 默认置为「线索转入」**(产品明确);直接创建的由用户选。 渠道归因字段 `oppSource`(字典码,必填,原型 A3-1-1-2-1)。表达商机从哪个渠道来(展会 / 转介绍 / 官网 等)。**与「来源线索」是两回事**——来源是渠道分类,每条商机都有;来源线索是血缘。**从线索转来的商机,`oppSource` 默认置为「线索转入」**(产品明确);直接创建的由用户选。
_Avoid_: 来源线索、渠道(口语,字段名 oppSource)、来路 _Avoid_: 来源线索、渠道(口语,字段名 oppSource)、来路
@ -39,15 +43,15 @@ _Avoid_: 来源线索、渠道(口语,字段名 oppSource)、来路
_Avoid_: 商机来源(那是 oppSource 渠道)、线索 id、parent _Avoid_: 商机来源(那是 oppSource 渠道)、线索 id、parent
**关联客户**: **关联客户**:
商机关联的客户(原型「添加关联客户」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)。⚠ `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)、`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 _Avoid_: 客户(客户是 A4 域的实体)、甲方(甲方是快照里的一个字段)、customer
**暂缓**: **暂缓**:
商机流转动作(原型 A3-1-1-2-6):短期推不动、但仍有价值时挂起。选**暂缓原因**(字典)+ **预计重启日期**(必填,不早于今天)+ 补充说明 → 状态「暂缓中」。**保留领取人+团队、不进公海、冻结回收计时**(暂缓期间不被超时回收;取消暂缓后按剩余天数续算,票 05)。保留暂缓前阶段/节点。到期不自动恢复——系统提醒领取人,人工选 取消暂缓(恢复推进)/ 继续暂缓 / 关闭。按钮可见性由权限点配置,可见即可点,与是否领取人无关。**暂缓期间一切编辑操作全禁,必须先取消暂缓回到推进中才能写**(写跟进/子表写同禁;P1-7 产品拍板 20260821)。暂缓中(3) 允许的只有 E5 取消暂缓/E6 关闭/E9 移交。 商机流转动作(原型 A3-1-1-2-6):短期推不动、但仍有价值时挂起。选**暂缓原因**(字典)+ **预计重启日期**(必填,不早于今天)+ 补充说明 → 状态「暂缓中」。附加字段落主表(`pause_time`=now/`pause_reason`/`pause_expected_restart_date`/`pause_remark`,E4 写、E5 清空;grill 拍板 20260821)。**保留领取人+团队、不进公海、冻结回收计时**(暂缓期间不被超时回收;取消暂缓时把 `last_valid_follow_time` 顺推暂缓时长实现冻结,见【取消暂缓】,不存剩余天数伪状态)。保留暂缓前阶段/节点。到期不自动恢复——系统提醒领取人,人工选 取消暂缓(恢复推进)/ 继续暂缓 / 关闭。按钮可见性由权限点配置,可见即可点,与是否领取人无关。**暂缓期间一切编辑操作全禁,必须先取消暂缓回到推进中才能写**(写跟进/子表写同禁;P1-7 产品拍板 20260821)。暂缓中(3) 允许的只有 E5 取消暂缓/E6 关闭/E9 移交。
_Avoid_: 中止(不存在)、挂起、冻结、关闭(关闭是明确无价值) _Avoid_: 中止(不存在)、挂起、冻结、关闭(关闭是明确无价值)
**取消暂缓**: **取消暂缓**:
暂缓的逆动作(产品补充):暂缓中 → 恢复推进,回到暂缓前阶段/节点。原型 A3 无独立页(推测详情页按钮)。按钮可见即可点,与领取人无关。 暂缓的逆动作(产品补充):暂缓中 → 恢复推进,回到暂缓前阶段/节点。原型 A3 无独立页(推测详情页按钮)。按钮可见即可点,与领取人无关。**回收锚点顺推(grill 拍板 20260821)**:取消暂缓时 `last_valid_follow_time += 暂缓时长`(把暂缓那段从未跟进时长扣除),实现「暂缓期间冻结回收」;回收判定本身是无状态实时比较(`now−last_valid_follow_time≥recycle_days`),**不存「剩余天数」**。
_Avoid_: 恢复(口语)、重启(重启是关闭的逆动作)、激活 _Avoid_: 恢复(口语)、重启(重启是关闭的逆动作)、激活
**抛公海**: **抛公海**:
@ -63,11 +67,11 @@ _Avoid_: 中止(不存在)、作废(线索用「作废」)、删除、
_Avoid_: 恢复、取消暂缓(取消暂缓是暂缓的逆动作)、激活 _Avoid_: 恢复、取消暂缓(取消暂缓是暂缓的逆动作)、激活
**移交**: **移交**:
负责人变更动作,**入口在团队成员页(原型 A3-1-1-1-8)**,非详情页顶部操作区。**移交只换领取人 `owner_user_id` + 姓名/部门快照同步刷新**(`owner_dept_id` 换为移交对象的部门,公海规则匹配锚点跟人走;grill 补拍 20260821),不动团队成员等其他。**无独立「转让」动作**——「转让只有负责人能转」不是额外 guard,而是移交入口本身在负责人管理的团队成员页里(原型:「当前用户只能维护其权限范围内的团队成员及权限设置」),团队管理权实际落在领取人手上。 负责人变更动作,**入口在团队成员页(原型 A3-1-1-1-8)**,非详情页顶部操作区。**移交只换领取人 `owner_user_id` + 姓名/部门快照同步刷新**(`owner_dept_id` 换为移交对象的部门,公海规则匹配锚点跟人走;grill 补拍 20260821),不动团队成员等其他。**移交=部门完整过户**(grill 拍板 20260821):移交后若被超时回收/抛公海,**回落新负责人部门的公海**,原部门不再可见/领回(`owner_dept_id`=当前归属部门快照,非原始血缘;原始血缘部门由不变字段 `origin_dept_id` 记录,仅审计追溯、不参与匹配/可见性)。**移交同步维护 team 表(grill 拍板 20260821)**:新负责人 upsert 一条 team 行角色标签=「商机负责人」;原负责人角色标签改为普通协作(权限列不动——角色不带权限)、**留在团队继续协作并落入「我参与的」**,保证 `owner_user_id` 与 team 中「商机负责人」角色行始终指同一人。**无独立「转让」动作**——「转让只有负责人能转」不是额外 guard,而是移交入口本身在负责人管理的团队成员页里(原型:「当前用户只能维护其权限范围内的团队成员及权限设置」),团队管理权实际落在领取人手上。
_Avoid_: 转让(无此独立动作)、转移、指派(分配才是指派) _Avoid_: 转让(无此独立动作)、转移、指派(分配才是指派)
**转项目**: **转项目**:
商机的下游终态动作:商机推到最后转成项目,商机进入「已转项目」终态、锁死不可回退(对称线索的「已转商机」)。**无独立「转项目」按钮动作**(grill 补拍 20260821)——标准路径 = 创建商机 → 填写方案卡 → **推送方案卡 → 系统自动创建项目**,推送成功即触发轴1 终态(E8)+ 轴2 固定节点;列表「转项目」按钮只是**推送流程快捷入口**。特殊路径 = 跳过商机录入、项目侧直接创建项目,可关联方案卡(选填,归 A5)。**关联即联动**(grill 补拍 20260821):项目创建成功且关联了商机(或商机下的方案卡)时,A5 回调商机模块触发 E8(2→5)+ 落固定节点;本模块暴露回调端口(入参 = 商机 id 或方案卡 id),校验关联合法性后执行 E8 侧效(幂等:已终态则拒绝/跳过)。**转项目端口**名字先占位——项目实体、项目查重规则(原型 A7-3-2-4)归 A5 项目模块,本域只承认终态、定占位契约。 商机的下游终态动作:商机推到最后转成项目,商机进入「已转项目」终态、锁死不可回退(对称线索的「已转商机」)。**无独立「转项目」按钮动作**(grill 补拍 20260821)——标准路径 = 创建商机 → 填写方案卡 → **推送方案卡 → 系统自动创建项目**(**本期 spin-out、不可达**,grill 拍板 20260821),推送成功即触发轴1 终态(E8)+ 轴2 固定节点;列表「转项目」按钮只是**推送流程快捷入口**。**本期 E8 仅特殊路径可达** = 跳过商机录入、项目侧直接创建项目,可关联方案卡(选填,归 A5)。**关联即联动**(grill 补拍 20260821):项目创建成功且关联了商机(或商机下的方案卡)时,A5 回调商机模块触发 E8(2→5)+ 落固定节点;本模块暴露回调端口(入参 = 商机 id 或方案卡 id),校验关联合法性后执行 E8 侧效(幂等:已终态则拒绝/跳过)。**转项目端口**名字已定死(grill 拍板 20260821,对称 lead→opp port)——商机侧 inbound `OpportunityConvertPort`(`ConvertToProjectCmd(opportunityId 必填幂等键, schemeCardId 选填, projectId)` + `OpportunityConvertException`),A5 调用、商机侧实现;幂等命中已终态=安静跳过。项目实体、项目查重规则(原型 A7-3-2-4)归 A5 项目模块,本域只承认终态、定契约。
_Avoid_: 成交、转化、结项、CreateProject(具体接口名留给 A5) _Avoid_: 成交、转化、结项、CreateProject(具体接口名留给 A5)
**已转项目**: **已转项目**:
@ -83,8 +87,8 @@ _Avoid_: 方案预算(方案卡字段,不同层次)、预计金额(旧
_Avoid_: 项目金额(商机主表字段,不同层次)、预计金额(旧名,已弃) _Avoid_: 项目金额(商机主表字段,不同层次)、预计金额(旧名,已弃)
**方案卡**: **方案卡**:
商机推进过程中沉淀「客户需求/预算/招标形式/介入阶段/方案状态」的业务单据,转项目的**基础档案**。住 `crm-opportunity``opportunity_scheme_card`(票 07,原型 A3-1-1-1-3)。**一商机可多张、一客户一卡**(`UNIQUE(opportunity_id, customer_id)`),每张必关联商机下一个关联客户(无孤立方案卡);字段集由绑定的**方案卡模板**版本驱动(动态字段);单卡状态 `草稿1/已提交2/已推送3`(「未创建」=0卡展示态,P0-4)。列表「方案卡状态/方案预算」列取**主要意向客户那张卡**(票 13,非主卡)。**推送方案卡成功 → 系统自动创建项目 = 「已转项目」联动触发源**(grill 补拍 20260821,替代票 06「任一方案卡转项目」口径)。 商机推进过程中沉淀「客户需求/预算/招标形式/介入阶段/方案状态」的业务单据,转项目的**基础档案**。住 `crm-opportunity``opportunity_scheme_card`(票 07,原型 A3-1-1-1-3)。**一商机可多张、一客户一卡**(`UNIQUE(opportunity_id, customer_id)`),每张必关联商机下一个关联客户(无孤立方案卡);字段集由绑定的**方案卡模板**版本驱动(动态字段);单卡状态 `草稿1/已提交2/已推送3`(「未创建」=0卡展示态,P0-4)。列表「方案卡状态/方案预算」列取**主要意向客户那张卡**(票 13,非主卡)。**任一方案卡推送成功 → 系统自动创建项目 = 「已转项目」联动触发源**(grill 补拍 20260821;**推送本期 spin-out、不可达**):一商机多客户多卡,其中任一张推送成功即触发整商机转项目终态(E8,2→5),**终态压制方案卡自身状态机**——其余草稿/已提交卡随即冻结为只读(`card_status<3` 可编辑的前提是商机未关闭/未终态)。**模板可用性grill 拍板 20260821)**:无发布中模板不能建卡入口置灰);候选匹配专用优先通用兵底;`crm-rule` 初始化 seed 植入一条全公司通用发布中模板作兵底字段集见 PRD 附录 A原型 A7-3-2-2 OCR 校准 11 字段),`crm-opportunity` 只消费
_Avoid_: 推送规则(A7-3-2-3)、查重规则(A7-3-2-4)——均随推送票本期做(grill 补拍 20260821),但与方案卡本体/字段模板分层,不混入本票范畴 _Avoid_: 推送规则(A7-3-2-3)、查重规则(A7-3-2-4)——均随推送票 spin-out(grill 拍板 20260821,翻转“本期做”),与方案卡本体/字段模板分层,不混入本票范畴
**现场勘察**: **现场勘察**:
记录商机某次到客户现场勘察的**结果单据**(现场条件/客户需求/安装环境/工程师建议 + 附件)。住 `crm-opportunity``opportunity_site_survey`(票 08,原型 A3-1-1-1-4)。**一商机可多条**(多次勘察保留历史不覆盖);**无状态机**(区别于方案卡);**不绑阶段节点**(自由记录);主要工程师从 **`crm-auth` 组织架构**选(`engineer_user_id`,非自由文本);**`apply_no` 申请单号人工录入选填**(外部勘察申请流程未对接,grill 拍板 20260821);附件**复用 `crm-file`**商机附件子表(`biz_type=SITE_SURVEY`+`biz_id`,票 09),不独立建表。 记录商机某次到客户现场勘察的**结果单据**(现场条件/客户需求/安装环境/工程师建议 + 附件)。住 `crm-opportunity``opportunity_site_survey`(票 08,原型 A3-1-1-1-4)。**一商机可多条**(多次勘察保留历史不覆盖);**无状态机**(区别于方案卡);**不绑阶段节点**(自由记录);主要工程师从 **`crm-auth` 组织架构**选(`engineer_user_id`,非自由文本);**`apply_no` 申请单号人工录入选填**(外部勘察申请流程未对接,grill 拍板 20260821);附件**复用 `crm-file`**商机附件子表(`biz_type=SITE_SURVEY`+`biz_id`,票 09),不独立建表。
@ -103,13 +107,17 @@ _Avoid_: 方案卡附件/勘察附件各自建表(已统一为一表)
_Avoid_: 中央工作计划模块(A1X,跨域,本票不定读写) _Avoid_: 中央工作计划模块(A1X,跨域,本票不定读写)
**操作日志**: **操作日志**:
商机的**字段级变更审计** `opportunity_oplog`(票 09,原型 A3-1-1-1-9):实体对象/字段名称/旧值/新值/操作说明/来源(系统自动 vs 人工)/时间/操作人。普通用户只读。**与线索 `lead_history` 口径不同**:线索是动作类型枚举(CLAIM/ASSIGN…)+JSON,商机是字段级旧新值审计。状态/阶段迁移另有专用快照表(`opportunity_status_history`/`opportunity_stage_history`),与本通用审计并存不重。 商机的**字段级变更审计** `opportunity_oplog`(票 09,原型 A3-1-1-1-9):实体对象/字段名称/旧值/新值/操作说明/来源(系统自动 vs 人工)/时间/操作人。普通用户只读。**双维度分类(grill 拍板 20260821)**:`op_kind` 记录形态(`FIELD_CHANGE` 有旧新值 / `ROW_ADD` 整行新增 / `ROW_DELETE` 整行删除带 `biz_ref`+快照)+ `log_type` 业务类别(原型 line 1745:字段变更/状态流转/阶段流转/附件上传/方案推送/子表增删 前端可见,`SYSTEM_INTERNAL` 记库不显)。子表整行增删走 `ROW_ADD`/`ROW_DELETE`(日志可读「删除关联客户:XX公司」非 `deleted 0→1` 废记录),删除留痕另靠 BaseEntity `updater_id`/`update_time`(不加 `deleted_by`)。默认全记 + 短黑名单直接不记(技术字段/`last_valid_follow_time`),黑名单 ∩ SYSTEM_INTERNAL = ∅。**与线索 `lead_history` 口径不同**:线索是动作类型枚举(CLAIM/ASSIGN…)+JSON,商机是字段级旧新值审计。状态/阶段迁移另有专用快照表(`opportunity_status_history`/`opportunity_stage_history`),与本通用审计并存不重。
_Avoid_: 线索 history 模型(动作枚举,口径不同)、状态/阶段快照表(专用表,非通用审计) _Avoid_: 线索 history 模型(动作枚举,口径不同)、状态/阶段快照表(专用表,非通用审计)
**催办/督办**: **催办/督办**:
管理侧督促功能(原型 A3-2-1-1-1 督办功能;管理列表/详情的「催办/批量催办」入口)。**本期不实现(spin-out 留白,grill 补拍 20260821)**——催办本质是向负责人发通知,平台通知基础设施未建(暂缓到期提醒发送渠道同因挂起);留白待通知能力就绪后与督办一并规划落地,本期管理视图隐藏相关按钮。 管理侧督促功能(原型 A3-2-1-1-1 督办功能;管理列表/详情的「催办/批量催办」入口)。**本期不实现(spin-out 留白,grill 补拍 20260821)**——催办本质是向负责人发通知,平台通知基础设施未建(暂缓到期提醒发送渠道同因挂起);留白待通知能力就绪后与督办一并规划落地,本期管理视图隐藏相关按钮。
_Avoid_: 写跟进(催办是督促不是跟进记录)、站内信自建(通知属平台能力,商机域不自建) _Avoid_: 写跟进(催办是督促不是跟进记录)、站内信自建(通知属平台能力,商机域不自建)
**待发通知**:
回收前提醒的**落地载体** `opportunity_pending_notice`(grill 拍板 20260821,§7.10):平台通知能力未建,回收 Job 不直接发提醒,而是 upsert 一条待发记录(`notice_type`+`target_user_id`+`plan_notify_time`+`payload_json`+`notice_status` 待发0/已发1/已失效2),等通知能力就绪后另起消费者补发。**通用表**:本期只 `RECYCLE_REMIND` 真实写入,预留 `PAUSE_EXPIRE`(暂缓到期)/`URGE`(催办)——三处「向负责人发通知」需求共用一张待发表,不各建。提醒发出前商机被回收/领取/暂缓/关闭 → 待发记录置已失效。
_Avoid_: 直接发通知(本期只落待发记录)、三种提醒各建表(统一一张通用待发表)
**视图(内置 + 自定义)**: **视图(内置 + 自定义)**:
销售机会工作区下的视图组织(票 14 推翻票 10「单一我负责的」,以 demo 0803 为准)。**内置视图四个**(「常用检索」Tab,固定不可删/改):**最近访问**(打开过详情的商机,时间倒序、按 id 去重留最新,上限可配 默认 50,落 `opportunity_view_log`)/ **我负责的** `owner=当前用户` / **我参与的**(团队成员且非负责人,见下)/ **我关注的**(关注表 `opportunity_focus` 命中,见下)。**自定义视图**(「自定义检索」Tab)= 用户保存的检索:检索名称 + 条件列表(字段∈列表可筛选列 + 操作符 等于/不等于/包含/不包含/为空/不为空 + 值)+ 排序(字段+asc/desc),可编辑/重命名/删除/图钉设默认——**落 `crm-preference` 平台能力**(用户私有、按 `scope_key` 分组,本期 scope=`opportunity.sales`,后续菜单加 scope 即复用)。**商机管理**(按 `@DataScope`)/ **商机公海**(`opp_status=1 且 owner 空` 逻辑视图)仍是独立 workspace、不进销售机会视图下拉、本期不开自定义视图。计算列不冗余存:节点停留=今−进入当前节点日(stage_history)、最近跟进=最新 follow、预计金额=`project_amount`。详情三皮肤(个人/管理/公海)=同一聚合按视角展不同操作区,非三套数据。 销售机会工作区下的视图组织(票 14 推翻票 10「单一我负责的」,以 demo 0803 为准)。**内置视图四个**(「常用检索」Tab,固定不可删/改):**最近访问**(打开过详情的商机,时间倒序、按 id 去重留最新,上限可配 默认 50,落 `opportunity_view_log`)/ **我负责的** `owner=当前用户` / **我参与的**(团队成员且非负责人,见下)/ **我关注的**(关注表 `opportunity_focus` 命中,见下)。**自定义视图**(「自定义检索」Tab)= 用户保存的检索:检索名称 + 条件列表(字段∈列表可筛选列 + 操作符 等于/不等于/包含/不包含/为空/不为空 + 值)+ 排序(字段+asc/desc),可编辑/重命名/删除/图钉设默认——**落 `crm-preference` 平台能力**(用户私有、按 `scope_key` 分组,本期 scope=`opportunity.sales`,后续菜单加 scope 即复用)。**商机管理**(按 `@DataScope`)/ **商机公海**(`opp_status=1 且 owner 空` 逻辑视图)仍是独立 workspace、不进销售机会视图下拉、本期不开自定义视图。计算列不冗余存:节点停留=今−进入当前节点日(stage_history)、最近跟进=最新 follow、预计金额=`project_amount`。详情三皮肤(个人/管理/公海)=同一聚合按视角展不同操作区,非三套数据。
_Avoid_: 视图=状态切片(同一商机可跨视图)、预计成交时间(产品二次拍板:商机列表删除该列、不上主表;仅方案卡内部字段票07)、自定义视图绑死商机(落 crm-preference 按 scope 通用,票 14)、内置视图可编辑(内置固定,只自定义视图可增删改) _Avoid_: 视图=状态切片(同一商机可跨视图)、预计成交时间(产品二次拍板:商机列表删除该列、不上主表;仅方案卡内部字段票07)、自定义视图绑死商机(落 crm-preference 按 scope 通用,票 14)、内置视图可编辑(内置固定,只自定义视图可增删改)
@ -120,7 +128,7 @@ _Avoid_: 形态=视图内容(两轴正交)、后端为形态单开接口(
**最近访问**: **最近访问**:
商机级个人浏览行为数据,落 `opportunity_view_log`(`user_id`+`opportunity_id`+`last_view_time`,`UNIQUE(user_id, opportunity_id)`;grill 补拍 20260821,票 10 未定落库方式)。**调用详情聚合接口即 upsert**,个人/管理/公海任意皮肤都算。**不写 `opportunity_oplog`**——访问是高频行为数据,oplog 是字段级审计轨迹(票 09 行为类别不含浏览),两者分离。表内历史行后台 Job 按容量/时间裁剪。「最近访问」视图 = 命中当前用户按 `last_view_time` 倒序取上限(默认 50)。 商机级个人浏览行为数据,落 `opportunity_view_log`(`user_id`+`opportunity_id`+`last_view_time`,`UNIQUE(user_id, opportunity_id)`;grill 补拍 20260821,票 10 未定落库方式)。**调用详情聚合接口即 upsert**,个人/管理/公海任意皮肤都算。**不写 `opportunity_oplog`**——访问是高频行为数据,oplog 是字段级审计轨迹(票 09 行为类别不含浏览),两者分离。表内历史行后台 Job 按容量/时间裁剪。「最近访问」视图 = 命中当前用户按 `last_view_time` 倒序取上限(默认 50)。
_Avoid_: 操作日志(访问不入审计)、浏览记录(统一叫最近访问) _Avoid_: 操作日志(访问不入审计)、浏览记录(统一叫最近访问)、视图=权限旁路(最近访问只是可见入口,领取/写跟进等动作仍走完整权限点+状态 CAS+公海规则校验)
**关注**: **关注**:
商机级个人收藏动作,落 `opportunity_focus`(`opportunity_id`+`user_id`,`UNIQUE(opportunity_id, user_id)`;grill 补拍 20260821,票 14 视图引用前此表未定义)。**纯收藏夹语义,不带通知**(通知属平台通知基础设施,未建)。关注/取关无需原因、物理删行、不入操作日志。「我关注的」视图 = 该表命中当前用户;管理/公海/最近访问视图的「关注」按钮读写此表。 商机级个人收藏动作,落 `opportunity_focus`(`opportunity_id`+`user_id`,`UNIQUE(opportunity_id, user_id)`;grill 补拍 20260821,票 14 视图引用前此表未定义)。**纯收藏夹语义,不带通知**(通知属平台通知基础设施,未建)。关注/取关无需原因、物理删行、不入操作日志。「我关注的」视图 = 该表命中当前用户;管理/公海/最近访问视图的「关注」按钮读写此表。
@ -135,7 +143,7 @@ _Avoid_: 全有或全无(批量不允许整批回滚)
_Avoid_: owner guard(无「操作人==负责人」拦截,可见即可点)、视图×动作拆分(颗粒度过细) _Avoid_: owner guard(无「操作人==负责人」拦截,可见即可点)、视图×动作拆分(颗粒度过细)
**我参与的**: **我参与的**:
销售机会内置视图之一(票 14):**我是该商机团队成员但不是负责人** —— `EXISTS(团队成员 WHERE opportunity_id=? AND user_id=当前用户) AND owner_user_id <> 当前用户`。团队成员表 = 票 02/04 建商机落的领取人=负责人 + 后续「添加团队成员」加入者。与「我负责的」互斥不重叠(负责人走 `mine`,非负责成员走 `participated`)。 销售机会内置视图之一(票 14):**我是该商机团队成员但不是负责人** —— `EXISTS(团队成员 WHERE opportunity_id=? AND user_id=当前用户 AND deleted=0) AND owner_user_id <> 当前用户`。团队成员表 = 票 02/04 建商机落的领取人=负责人 + 后续「添加团队成员」加入者。**成员身份判定一律带存活过滤 `deleted=0`**(grill 拍板 20260821:移除成员=软删 team 行,移除后 `EXISTS` 自动失效,从本视图与详情白名单消失)。与「我负责的」互斥不重叠(负责人走 `mine`,非负责成员走 `participated`)。
_Avoid_: 参与=含负责人(负责人归「我负责的」,参与专指非负责成员)、参与=仅能看(团队成员带角色权限,非观察者) _Avoid_: 参与=含负责人(负责人归「我负责的」,参与专指非负责成员)、参与=仅能看(团队成员带角色权限,非观察者)
**进入公海原因**: **进入公海原因**:
@ -157,7 +165,7 @@ _Avoid_: 阶段字典项(被引用方)、current_stage(运行时指针)
_Avoid_: 商机公海池(无池实体)、商机池、线索池 _Avoid_: 商机公海池(无池实体)、商机池、线索池
**回收天数**: **回收天数**:
商机公海规则字段 `recycle_days`(`auto_recycle_enabled` 时必填):连续 `recycle_days` 天无**有效跟进**则超时回收→待领取,清 owner(票 05 `executeRecycle`)。回收前 `remind_days` 天提醒当前负责人(`remind_days` < `recycle_days` 硬校验)。**回收/提醒由 crm-opportunity 自建 `@Scheduled` Job 串行**(先扫提醒再扫回收对齐线索域 ADR-0019 竞态规避cron 配置占位符默认每日凌晨grill 补拍 20260821)。**无失效天数 M**(商机无失效态 04/05)。 商机公海规则字段 `recycle_days`(`auto_recycle_enabled` 时必填):连续 `recycle_days` 天无**有效跟进**则超时回收→待领取,清 owner(票 05 `executeRecycle`)。回收前 `remind_days` 天提醒当前负责人(`remind_days` < `recycle_days` 硬校验)。**回收/提醒由 crm-opportunity 自建 `@Scheduled` Job 串行**(先扫提醒再扫回收对齐线索域 ADR-0019 竞态规避cron 配置占位符默认每日凌晨grill 补拍 20260821)。**提醒只落待发记录不直接发送**(grill 拍板 20260821):Job upsert 一条 `opportunity_pending_notice`实际发送等平台通知能力spin-out 留白)。**无失效天数 M**(商机无失效态 04/05)。
_Avoid_: 失效天数(商机无)、超时天数、N _Avoid_: 失效天数(商机无)、超时天数、N
**有效跟进**: **有效跟进**:
@ -166,6 +174,6 @@ _Avoid_: 浏览(不算有效跟进)、编辑主表字段(不计)、上
## 待细化(后续票) ## 待细化(后续票)
- 「移交」按钮原型位置、「取消暂缓/重启」独立页存否 —— 见 `issues/01` 的 To-verify,以产品口径为准。 - 「移交」按钮原型位置、「取消暂缓/重启」独立页存否 —— 见 `issues/01` 的 To-verify,以产品口径为准。
- 商机主表 + 子表(团队成员 / 方案卡 / 现场勘察 / 跟进 / 附件 / 操作日志)字段(票 03/07/08/09)。 - ~~商机主表字段~~ **票 03 已落地**(`Opportunity` 实体 + `OpportunityMapper`,三簇 + Amendment 1 + 乐观锁 version);子表(团队成员 / 方案卡 / 现场勘察 / 跟进 / 附件 / 操作日志)字段仍归后续票(07/08/09)。
- 商机公海规则已定(票 05,版本化配置 `opportunity_pool_rule`,原型 A7-3-2-5);四视图查询与「我的关注」表(票 10)。 - 商机公海规则已定(票 05,版本化配置 `opportunity_pool_rule`,原型 A7-3-2-5);四视图查询与「我的关注」表(票 10)。
- `crm-rule` 从「线索规则」升级为「通用业务规则域」的 CONTEXT.md 改写(本决策副作用)。 - `crm-rule` 从「线索规则」升级为「通用业务规则域」的 CONTEXT.md 改写(本决策副作用)。

58
crm-opportunity/pom.xml

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.crm</groupId>
<artifactId>crm</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>crm-opportunity</artifactId>
<packaging>jar</packaging>
<name>crm-opportunity</name>
<description>CRM 商机业务模块:状态+阶段双轴/四视图/公海/暂缓/移交/转项目</description>
<!-- 依赖方向单向:crm-opportunity → crm-rule / crm-auth / crm-dict / crm-file / crm-preference / crm-base -->
<dependencies>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-base</artifactId>
</dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-auth</artifactId>
</dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-rule</artifactId>
</dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-dict</artifactId>
</dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-file</artifactId>
</dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-preference</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

203
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java

@ -0,0 +1,203 @@
package com.crm.opportunity.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.crm.base.annotation.DataScope;
import com.crm.base.domain.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.Comment;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 商机主表 03 三簇定稿 + Amendment 1 项目金额 / PRD §2
* <p>两条正交轴 1 归属流转状态 {@code oppStatus}5 {@link com.crm.opportunity.domain.enums.OpportunityStatus}
* + 2 业务阶段指针 {@code currentStageId}指向 opportunity_stage_node模板版本锁死于 {@code stageTemplateId}
* 归属三层owner_user_id领取人抛公海清空+ owner_dept_id部门快照DataScope/公海可见锚点移交跟人走
* + origin_dept_id原始血缘永不变仅审计单计时器last_valid_follow_time回收锚点无失效计时器
* 暂缓附加字段四列落主表E4 / E5 清空乐观锁 {@code version}切主客户票 13 / 状态 CAS 04 复用
* 生效接线归后续票来源线索快照四列 + 主客户冗余快照两列</p>
* <p>数据范围{@code @DataScope(module="opportunity", ownerColumn="owner_user_id", deptColumn="owner_dept_id")}
* owner_dept_id 做部门天花板 owner_user_id 我负责的过滤</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity")
@DataScope(module = "opportunity", ownerColumn = "owner_user_id", deptColumn = "owner_dept_id")
@Entity
@Table(name = "opportunity", indexes = {
@Index(name = "idx_source_lead", columnList = "source_lead_id", unique = true),
@Index(name = "idx_owner_status", columnList = "owner_user_id, opp_status"),
@Index(name = "idx_owner_dept_status", columnList = "owner_dept_id, opp_status")
})
public class Opportunity extends BaseEntity {
// -------- 簇 A 业务字段(原型 A3-1-1-2-1)--------
@Comment("商机名称")
@Column(columnDefinition = "varchar(200) not null comment '商机名称'")
private String oppName;
@Comment("商机来源字典 code(渠道归因,线索转入时默认置「线索转入」)")
@Column(columnDefinition = "varchar(64) not null comment '商机来源字典code'")
private String oppSource;
@Comment("商机类型字典 code")
@Column(columnDefinition = "varchar(64) not null comment '商机类型字典code'")
private String oppType;
@Comment("行业字典 code")
@Column(columnDefinition = "varchar(64) not null comment '行业字典code'")
private String industryCode;
@Comment("招标形式字典 code")
@Column(columnDefinition = "varchar(64) not null comment '招标形式字典code'")
private String bidForm;
@Comment("项目属地字典 code(本地/异地分类标签,非地理坐标)")
@Column(columnDefinition = "varchar(64) not null comment '项目属地字典code'")
private String localityType;
@Comment("甲方是否明确 0/1")
@Column(columnDefinition = "tinyint not null comment '甲方是否明确'")
private Integer partyAClear;
@Comment("甲方名称(party_a_clear=1 时必填)")
@Column(columnDefinition = "varchar(200) comment '甲方名称'")
private String partyA;
@Comment("省份国标 code(GB/T 2260,6位)")
@Column(columnDefinition = "varchar(12) not null comment '省份国标code'")
private String provinceCode;
@Comment("市/区国标 code(对称 lead 约定:市码或区/县码均可,不单建 district_code)")
@Column(columnDefinition = "varchar(12) not null comment '市/区国标code'")
private String cityCode;
@Comment("备注")
@Column(columnDefinition = "text comment '备注'")
private String remark;
@Comment("项目金额(项目整体总包,人民币元,选填可后补;票 03 Amendment 1)")
@Column(columnDefinition = "decimal(14,2) comment '项目金额'")
private BigDecimal projectAmount;
// -------- 簇 B1 归属(票 01/02)--------
@Comment("领取人ID(归属+可见性;抛公海/回收时清空)")
@Column(columnDefinition = "bigint comment '领取人ID'")
private Long ownerUserId;
@Comment("领取人姓名快照")
@Column(columnDefinition = "varchar(64) comment '领取人姓名快照'")
private String ownerNameSnapshot;
@Comment("领取人部门快照(DataScope/公海可见范围锚点;移交换为移交对象部门,跟人走)")
@Column(columnDefinition = "bigint comment '领取人部门快照'")
private Long ownerDeptId;
@Comment("原始血缘部门(创建时写入=首个负责人部门,永不变;仅审计追溯,不参与匹配/可见性)")
@Column(columnDefinition = "bigint comment '原始血缘部门'")
private Long originDeptId;
@Comment("进入公海原因字典 code(销售主动释放/特殊原因暂停/领导释放/超期回收)")
@Column(columnDefinition = "varchar(32) comment '进入公海原因'")
private String poolReason;
/**
* 创建人 ID 03 B1bigint 业务外键全生命周期不变
* <p> BaseEntity {@code creatorId}varchar 审计列MetaObjectFillHandler 自动填充是两列
* 本字段是规格要求的领域字段后续权限/审计判定用 Long 直比不复用不依赖审计列的字符串形态</p>
*/
@Comment("创建人ID(全生命周期不变;直接创建/线索转入均落创建人)")
@Column(columnDefinition = "bigint not null comment '创建人ID'")
private Long creatorUserId;
// -------- 簇 B2 两条正交轴 --------
@Comment("商机状态(轴1归属流转:1待领取/2推进中/3暂缓中/4已关闭/5已转项目,取值票04)")
@Column(columnDefinition = "tinyint not null comment '商机状态'")
private Integer oppStatus;
@Comment("当前阶段节点ID(轴2业务阶段,→opportunity_stage_node.id;新建默认落首节点)")
@Column(columnDefinition = "bigint not null comment '当前阶段节点ID'")
private Long currentStageId;
@Comment("阶段模板版本行ID(→opportunity_stage_template.id,版本级锁死,发新版不迁移)")
@Column(columnDefinition = "bigint not null comment '阶段模板版本行ID'")
private Long stageTemplateId;
@Comment("阶段模板版本号冗余(如 V1.2,展示用免 join;版本锁由 stage_template_id 保证)")
@Column(columnDefinition = "varchar(20) not null comment '阶段模板版本号冗余'")
private String stageTemplateVersion;
// -------- 簇 B3 来源线索快照(票 02)--------
@Comment("来源线索ID(幂等键 UNIQUE,NULL 不参与唯一=直接创建可多条;两条入口都可写)")
@Column(columnDefinition = "bigint comment '来源线索ID'")
private Long sourceLeadId;
@Comment("线索名快照")
@Column(columnDefinition = "varchar(200) comment '线索名快照'")
private String sourceLeadName;
@Comment("联系电话快照")
@Column(columnDefinition = "varchar(32) comment '联系电话快照'")
private String sourcePhone;
@Comment("需求产品码快照")
@Column(columnDefinition = "varchar(64) comment '需求产品码快照'")
private String sourceProductCode;
// -------- 簇 B4 计时器(票 05:仅回收,无失效)--------
@Comment("领取时刻(归属起点;抛公海时不清,对称 lead)")
@Column(columnDefinition = "datetime comment '领取时刻'")
private LocalDateTime claimTime;
@Comment("最近有效跟进时间(自动回收计时锚点,回收判定=now-锚点≥recycle_days 实时算)")
@Column(columnDefinition = "datetime comment '最近有效跟进时间'")
private LocalDateTime lastValidFollowTime;
// -------- C3 主要意向客户冗余(票 13 子表 is_primary_intended=1 那条)--------
@Comment("主要意向客户ID(冗余快查,=关联客户表主标记行的 customer_id;引用 A4,未建)")
@Column(columnDefinition = "bigint comment '主要意向客户ID'")
private Long primaryCustomerId;
@Comment("主客户名快照(列表/详情顶部免 join;切主客户时同步刷新)")
@Column(columnDefinition = "varchar(200) comment '主客户名快照'")
private String primaryCustomerNameSnapshot;
// -------- 暂缓附加字段(E4 写=now,E5 取消暂缓清空)--------
@Comment("暂缓开始时刻(取消暂缓时按 now-pause_time 顺推回收锚点)")
@Column(columnDefinition = "datetime comment '暂缓开始时刻'")
private LocalDateTime pauseTime;
@Comment("暂缓原因字典 code")
@Column(columnDefinition = "varchar(32) comment '暂缓原因'")
private String pauseReason;
@Comment("预计重启日期(必填且不早于今天)")
@Column(columnDefinition = "date comment '预计重启日期'")
private LocalDate pauseExpectedRestartDate;
@Comment("暂缓补充说明")
@Column(columnDefinition = "varchar(500) comment '暂缓补充说明'")
private String pauseRemark;
// -------- 乐观锁(切主客户票13 / 状态 CAS 票04 复用)--------
@Comment("乐观锁版本号(商机级写并发防护:切主客户/状态迁移 CAS)")
@Version
@Column(columnDefinition = "int not null default 0 comment '乐观锁版本号'")
private Integer version = 0;
}

35
crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OpportunityStatus.java

@ -0,0 +1,35 @@
package com.crm.opportunity.domain.enums;
import com.crm.base.domain.enums.HasValueEnum;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 商机归属流转状态 1 03 B2 / PRD §3.1
* <p>5 态单字段 {@code opp_status} tinyint1 待领取(在公海) / 2 推进中(默认活跃态) /
* 3 暂缓中 / 4 已关闭(半终态可重启) / 5 已转项目(绝对终态无出边)
* 与业务阶段 2 {@code current_stage_id}正交状态管归属处境
* 不管业务打法走到第几步迁移全图归票 04本枚举只钉值域</p>
*/
@Getter
@RequiredArgsConstructor
public enum OpportunityStatus implements HasValueEnum<Integer> {
/** 待领取(在公海):owner 为空,躺在商机公海(逻辑视图,无池实体) */
STATUS_IN_POOL(1, "待领取"),
/** 推进中(默认活跃态):有领取人、正常推进 */
STATUS_ADVANCING(2, "推进中"),
/** 暂缓中:短期挂起,保留领取人与团队,冻结回收计时 */
STATUS_PAUSED(3, "暂缓中"),
/** 已关闭(半终态):只读锁定,可重启 */
STATUS_CLOSED(4, "已关闭"),
/** 已转项目(绝对终态):锁死,无任何出边 */
STATUS_CONVERTED(5, "已转项目");
private final Integer value;
private final String label;
}

14
crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityMapper.java

@ -0,0 +1,14 @@
package com.crm.opportunity.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.opportunity.domain.entity.Opportunity;
import org.apache.ibatis.annotations.Mapper;
/**
* 商机主表 Mapper 03 骨架
* <p>同构 crm-lead LeadMapper 写法仅继承 CrmBaseMapper 拿基础 CRUD + batchUpsert
* 四视图/公海/状态机 CAS 等查询与更新归后续票04/05/10按需追加方法</p>
*/
@Mapper
public interface OpportunityMapper extends CrmBaseMapper<Opportunity> {
}

193
crm-opportunity/src/test/java/com/crm/opportunity/domain/entity/OpportunityTest.java

@ -0,0 +1,193 @@
package com.crm.opportunity.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.crm.base.annotation.DataScope;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 商机主表实体表结构契约测试 03 三簇 + Amendment 1 / PRD §2
* <p>纯数据实体无行为可测测试价值在钉死表结构契约
* 字段全集防漏建/防脑补@DataScope 锚点列索引集乐观锁 version
* 状态机迁移逻辑归票 04本票不测流转</p>
*/
@DisplayName("商机主表 Opportunity 表结构契约(票 03 / PRD §2)")
class OpportunityTest {
private static final Set<String> FIELDS = declaredFields();
private static Set<String> declaredFields() {
return Arrays.stream(Opportunity.class.getDeclaredFields())
.filter(f -> !Modifier.isStatic(f.getModifiers()))
.map(Field::getName)
.collect(Collectors.toSet());
}
private static Field field(String name) throws NoSuchFieldException {
return Opportunity.class.getDeclaredField(name);
}
/** 读取字段上的 @jakarta.persistence.Column 的 columnDefinition */
private static String columnDef(String name) throws NoSuchFieldException {
jakarta.persistence.Column column = field(name)
.getAnnotation(jakarta.persistence.Column.class);
assertThat(column).as("字段 %s 应有 @Column", name).isNotNull();
return column.columnDefinition();
}
@Test
@DisplayName("字段全集 = 票 03 三簇 + 暂缓附加 + Amendment 1 项目金额 + 乐观锁,无漏无多")
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",
// 簇 B1 归属
"ownerUserId", "ownerNameSnapshot", "ownerDeptId", "originDeptId",
"poolReason", "creatorUserId",
// 簇 B2 两条正交轴
"oppStatus", "currentStageId", "stageTemplateId", "stageTemplateVersion",
// 簇 B3 来源线索快照
"sourceLeadId", "sourceLeadName", "sourcePhone", "sourceProductCode",
// 簇 B4 计时器
"claimTime", "lastValidFollowTime",
// C3 主要意向客户冗余快照
"primaryCustomerId", "primaryCustomerNameSnapshot",
// 暂缓附加字段(PRD §2.2,E4 写 E5 清空)
"pauseTime", "pauseReason", "pauseExpectedRestartDate", "pauseRemark",
// 乐观锁(切主客户票 13 / 状态 CAS 票 04 复用)
"version");
}
@Test
@DisplayName("不建票 05 明确排除的字段:pool_id / dept_id / recycle_deadline / expected_deal_date")
void excludedFields_notPresent() {
assertThat(FIELDS).doesNotContain("poolId", "deptId", "teamDeptId",
"recycleDeadline", "expireDeadline", "expectedDealDate");
}
@Test
@DisplayName("簇 C2:@DataScope(module=opportunity, owner=owner_user_id, dept=owner_dept_id)")
void dataScopeAnnotation_anchorsOwnerAndDept() {
DataScope anno = Opportunity.class.getAnnotation(DataScope.class);
assertThat(anno).isNotNull();
assertThat(anno.module()).isEqualTo("opportunity");
assertThat(anno.ownerColumn()).isEqualTo("owner_user_id");
assertThat(anno.deptColumn()).isEqualTo("owner_dept_id");
}
@Test
@DisplayName("表名 opportunity(MyBatis-Plus 运行时 + JPA 建表一致,非保留字无需反引号)")
void tableName_opportunity() {
TableName tableName = Opportunity.class.getAnnotation(TableName.class);
assertThat(tableName).isNotNull();
assertThat(tableName.value()).isEqualTo("opportunity");
Table table = Opportunity.class.getAnnotation(Table.class);
assertThat(table).isNotNull();
assertThat(table.name()).isEqualTo("opportunity");
}
@Test
@DisplayName("PRD §2.4 核心索引:UNIQUE(source_lead_id) + (owner_user_id,opp_status) + (owner_dept_id,opp_status)")
void coreIndexes_perPrd24() {
Table table = Opportunity.class.getAnnotation(Table.class);
Map<String, Index> indexes = Arrays.stream(table.indexes())
.collect(Collectors.toMap(Index::name, Function.identity()));
assertThat(indexes).containsOnlyKeys("idx_source_lead", "idx_owner_status", "idx_owner_dept_status");
// 幂等键:一条线索只能关联一条商机(票 02,NULL 不参与唯一)
assertThat(indexes.get("idx_source_lead").unique()).isTrue();
assertThat(indexes.get("idx_source_lead").columnList()).isEqualTo("source_lead_id");
// 「我负责的」视图
assertThat(indexes.get("idx_owner_status").columnList()).isEqualTo("owner_user_id, opp_status");
// 公海视图(opp_status=1 逻辑视图)+ 部门天花板
assertThat(indexes.get("idx_owner_dept_status").columnList()).isEqualTo("owner_dept_id, opp_status");
}
@Test
@DisplayName("乐观锁 version:@Version 注解 + 实体默认 0(切主客户/状态 CAS 复用锚点)")
void version_optimisticLockAnchor() throws NoSuchFieldException {
assertThat(field("version").getAnnotation(Version.class)).isNotNull();
assertThat(field("version").getType()).isEqualTo(Integer.class);
assertThat(new Opportunity().getVersion()).isEqualTo(0);
assertThat(columnDef("version")).contains("default 0");
}
@Test
@DisplayName("Amendment 1:project_amount decimal(14,2) 选填(人民币元,不建币种字段)")
void projectAmount_decimal142_nullable() throws NoSuchFieldException {
assertThat(field("projectAmount").getType()).isEqualTo(BigDecimal.class);
assertThat(columnDef("projectAmount")).contains("decimal(14,2)");
assertThat(columnDef("projectAmount")).doesNotContain("not null");
}
@Test
@DisplayName("两条轴落点:opp_status tinyint 必填;current_stage_id / stage_template_id / 版本号必填")
void twoAxes_columns() throws NoSuchFieldException {
assertThat(columnDef("oppStatus")).contains("tinyint").contains("not null");
assertThat(columnDef("currentStageId")).contains("bigint").contains("not null");
assertThat(columnDef("stageTemplateId")).contains("bigint").contains("not null");
assertThat(columnDef("stageTemplateVersion")).contains("varchar(20)").contains("not null");
}
@Test
@DisplayName("暂缓附加字段落主表:pause_expected_restart_date 为 date(LocalDate),remark 组类型对齐 PRD §2.2")
void pauseFields_types() throws NoSuchFieldException {
assertThat(field("pauseTime").getType()).isEqualTo(LocalDateTime.class);
assertThat(field("pauseReason").getType()).isEqualTo(String.class);
assertThat(field("pauseExpectedRestartDate").getType()).isEqualTo(LocalDate.class);
assertThat(field("pauseRemark").getType()).isEqualTo(String.class);
assertThat(columnDef("pauseReason")).contains("varchar(32)");
assertThat(columnDef("pauseRemark")).contains("varchar(500)");
}
@Test
@DisplayName("归属与计时器:owner 系列可空(抛公海清空)、creator_user_id 必填、回收锚点 datetime")
void ownershipAndTimers_columns() throws NoSuchFieldException {
// 抛公海清空 owner_user_id/姓名快照;owner_dept_id 保留作公海可见范围锚点(票 05)
assertThat(columnDef("ownerUserId")).contains("bigint").doesNotContain("not null");
assertThat(columnDef("ownerNameSnapshot")).contains("varchar(64)").doesNotContain("not null");
assertThat(columnDef("ownerDeptId")).contains("bigint").doesNotContain("not null");
// 原始血缘部门:创建时写入永不变,仅审计(PRD §2.2 / 移交 grill 拍板 20260821)
assertThat(columnDef("originDeptId")).contains("bigint").doesNotContain("not null");
assertThat(columnDef("creatorUserId")).contains("bigint").contains("not null");
assertThat(columnDef("claimTime")).contains("datetime").doesNotContain("not null");
assertThat(columnDef("lastValidFollowTime")).contains("datetime").doesNotContain("not null");
assertThat(columnDef("poolReason")).contains("varchar(32)");
}
@Test
@DisplayName("来源快照 + 主客户冗余:source_lead_id 可空、primary_customer_* 不带 not null")
void sourceAndPrimaryCustomer_columns() throws NoSuchFieldException {
assertThat(columnDef("sourceLeadId")).contains("bigint").doesNotContain("not null");
assertThat(columnDef("sourceLeadName")).contains("varchar(200)");
assertThat(columnDef("sourcePhone")).contains("varchar(32)");
assertThat(columnDef("sourceProductCode")).contains("varchar(64)");
assertThat(columnDef("primaryCustomerId")).contains("bigint").doesNotContain("not null");
assertThat(columnDef("primaryCustomerNameSnapshot")).contains("varchar(200)");
}
@Test
@DisplayName("簇 C1:继承 BaseEntity(id/审计四列/逻辑删除复用,不自建)")
void extendsBaseEntity() {
assertThat(new Opportunity()).isInstanceOf(com.crm.base.domain.entity.BaseEntity.class);
}
}

53
crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/OpportunityStatusTest.java

@ -0,0 +1,53 @@
package com.crm.opportunity.domain.enums;
import com.crm.base.utils.EnumUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 商机状态枚举值域单测 03 B2 / PRD §3.1
* <p> 1 归属流转 5 1 待领取(在公海) / 2 推进中 / 3 暂缓中 / 4 已关闭 / 5 已转项目
* 本票只钉值域与名称迁移逻辑归票 04</p>
*/
@DisplayName("商机状态枚举 OpportunityStatus(票 03 / PRD §3.1)")
class OpportunityStatusTest {
@Test
@DisplayName("5 态取值域:1待领取/2推进中/3暂缓中/4已关闭/5已转项目")
void valueDomain_fiveStates() {
assertThat(OpportunityStatus.STATUS_IN_POOL.getValue()).isEqualTo(1);
assertThat(OpportunityStatus.STATUS_ADVANCING.getValue()).isEqualTo(2);
assertThat(OpportunityStatus.STATUS_PAUSED.getValue()).isEqualTo(3);
assertThat(OpportunityStatus.STATUS_CLOSED.getValue()).isEqualTo(4);
assertThat(OpportunityStatus.STATUS_CONVERTED.getValue()).isEqualTo(5);
}
@Test
@DisplayName("枚举全集恰为 5 态,无多余值")
void valueDomain_exactlyFiveConstants() {
assertThat(OpportunityStatus.values()).hasSize(5);
}
@Test
@DisplayName("label 对齐 PRD §3.1 名称列")
void labels_matchPrd() {
assertThat(OpportunityStatus.STATUS_IN_POOL.getLabel()).isEqualTo("待领取");
assertThat(OpportunityStatus.STATUS_ADVANCING.getLabel()).isEqualTo("推进中");
assertThat(OpportunityStatus.STATUS_PAUSED.getLabel()).isEqualTo("暂缓中");
assertThat(OpportunityStatus.STATUS_CLOSED.getLabel()).isEqualTo("已关闭");
assertThat(OpportunityStatus.STATUS_CONVERTED.getLabel()).isEqualTo("已转项目");
}
@Test
@DisplayName("EnumUtils.getByValue 按 tinyint 值反查(实体 Integer 落库 ↔ 枚举互转)")
void getByValue_roundTrip() {
assertThat(EnumUtils.getByValue(OpportunityStatus.class, 1)).isEqualTo(OpportunityStatus.STATUS_IN_POOL);
assertThat(EnumUtils.getByValue(OpportunityStatus.class, 2)).isEqualTo(OpportunityStatus.STATUS_ADVANCING);
assertThat(EnumUtils.getByValue(OpportunityStatus.class, 3)).isEqualTo(OpportunityStatus.STATUS_PAUSED);
assertThat(EnumUtils.getByValue(OpportunityStatus.class, 4)).isEqualTo(OpportunityStatus.STATUS_CLOSED);
assertThat(EnumUtils.getByValue(OpportunityStatus.class, 5)).isEqualTo(OpportunityStatus.STATUS_CONVERTED);
assertThat(EnumUtils.getByValue(OpportunityStatus.class, 99)).isNull();
}
}

56
crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityMapperTest.java

@ -0,0 +1,56 @@
package com.crm.opportunity.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.opportunity.domain.entity.Opportunity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.annotations.Mapper;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 商机主表 Mapper 骨架单测 03
* <p>本票只落 mapper 骨架同构 LeadMapper继承 CrmBaseMapper 拿基础 CRUD + batchUpsert
* 并借 TableInfoHelper 验证 MyBatis-Plus 运行时表绑定表名 + 驼峰列映射
* 为后续票的 LambdaQueryWrapper 提前初始化 lambda 缓存</p>
*/
@DisplayName("商机主表 Mapper 骨架 OpportunityMapper(票 03)")
class OpportunityMapperTest {
/**
* 纯单元测试无 SqlSessionFactoryTableInfoHelper 初始化 MyBatis-Plus 元数据缓存
*/
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, Opportunity.class);
}
@Test
@DisplayName("继承 CrmBaseMapper<Opportunity>:基础 CRUD + batchUpsert 能力(仓库规约)")
void extendsCrmBaseMapper() {
assertThat(CrmBaseMapper.class).isAssignableFrom(OpportunityMapper.class);
}
@Test
@DisplayName("@Mapper 注解:可被 @MapperScan(com.crm.**.mapper) 扫描注册")
void mapperAnnotationPresent() {
assertThat(OpportunityMapper.class.getAnnotation(Mapper.class)).isNotNull();
}
@Test
@DisplayName("运行时表绑定:表名解析为 opportunity,驼峰映射抽查 opp_status↔oppStatus")
void tableInfo_resolvesOpportunity() {
TableInfo info = TableInfoHelper.getTableInfo(Opportunity.class);
assertThat(info).isNotNull();
assertThat(info.getTableName()).isEqualTo("opportunity");
assertThat(info.getFieldList()).anyMatch(f ->
"opp_status".equals(f.getColumn()) && "oppStatus".equals(f.getProperty()));
}
}

6
pom.xml

@ -26,6 +26,7 @@
<module>crm-rule</module> <module>crm-rule</module>
<module>crm-preference</module> <module>crm-preference</module>
<module>crm-lead</module> <module>crm-lead</module>
<module>crm-opportunity</module>
<module>crm-app</module> <module>crm-app</module>
</modules> </modules>
@ -84,6 +85,11 @@
<artifactId>crm-lead</artifactId> <artifactId>crm-lead</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-opportunity</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.crm</groupId> <groupId>com.crm</groupId>

Loading…
Cancel
Save