Browse Source

feat(opportunity): 票 09 子表族结构落地(跟进/附件/工作计划/操作日志)

§7.4–7.7 四张表实体 + Mapper 骨架(对齐票 03 粒度,只落结构不接线):
- OpportunityFollow 跟进记录:无草稿态,软删+delete_key,支持删除不回退锚点,本期不做编辑
- OpportunityAttachment 统一附件表:biz_type+biz_id 定位(AttachmentBizType 枚举
  OPP/FOLLOW_UP/SITE_SURVEY/SCHEME_CARD),文件本体走 crm-file,软删+delete_key;
  是票 07/08 的附件契约落点
- OpportunityWorkPlan 工作计划:结构 only,plan_status 0/1,精确态枚举与联动归 A1X
- OpportunityOplog 操作日志:字段级审计,双维度 OplogKind(FIELD_CHANGE/ROW_ADD/
  ROW_DELETE)+ OplogLogType(7 类,SYSTEM_INTERNAL 记库不显),只增不删无 delete_key,
  带 biz_ref + snapshotJson

测试:OpportunityChildTablesMapperTest(表绑定+列映射+逻辑删)+ EnumTest(值域),
crm-opportunity 100→108 全绿。CONTEXT/底稿落地记录回填。

接线待后续:跟进/附件读写 Service/Controller、统一审计切面写 oplog、
last_valid_follow_time 联动、crm-dict 跟进方式/结果标签/资料类型分组
master
luoweijian 2 weeks ago
parent
commit
825cb2cf9a
  1. 17
      .scratch/opportunity-module/issues/09-child-tables-followup-attachment-log.md
  2. 3
      crm-opportunity/CONTEXT.md
  3. 63
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityAttachment.java
  4. 75
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFollow.java
  5. 92
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityOplog.java
  6. 58
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityWorkPlan.java
  7. 33
      crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/AttachmentBizType.java
  8. 30
      crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogKind.java
  9. 43
      crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogLogType.java
  10. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityAttachmentMapper.java
  11. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFollowMapper.java
  12. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityOplogMapper.java
  13. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityWorkPlanMapper.java
  14. 44
      crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/OpportunityChildTablesEnumTest.java
  15. 91
      crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityChildTablesMapperTest.java

17
.scratch/opportunity-module/issues/09-child-tables-followup-attachment-log.md

@ -116,3 +116,20 @@ Blocked by: 03
- 🅿️ 工作计划整块(Tab 读 A1X + 执行状态精确枚举)= 独立模块 A1X,本期挂起(P1-4 拍板,见 P5 段)。
- ✅ 操作日志「关键字段」范围 = 默认全记 + 短黑名单排除(P1-5 拍板,见 P6 写入点)。
- ⚠ 各类字典具体选项集(跟进方式/结果标签/资料类型)待产品。
---
## 落地记录(20260825)
**结构落地完成**(对齐票 03「实体+Mapper骨架」粒度,§7.4–7.7 四张表):
- 实体:`OpportunityFollow` / `OpportunityAttachment` / `OpportunityWorkPlan` / `OpportunityOplog`(均 `@Entity`+`@TableName`+继承 `BaseEntity`)。
- 枚举:`AttachmentBizType`(4 值)/ `OplogKind`(3 值 FIELD_CHANGE/ROW_ADD/ROW_DELETE)/ `OplogLogType`(7 值,SYSTEM_INTERNAL 记库不显)。
- Mapper:四个 `*Mapper extends CrmBaseMapper`
- 软删口径:跟进/附件/工作计划 = 软删 + `delete_key`(存活0/删写id);操作日志 = 只增不删无 `delete_key`
- 测试:`OpportunityChildTablesMapperTest`(表绑定+列映射+逻辑删验证)+ `OpportunityChildTablesEnumTest`(值域),crm-opportunity 100→108 全绿。
**接线待后续票**(本票只落结构):
- 跟进读写 Service/Controller + 保存跟进刷 `last_valid_follow_time`(票 05 联动)+ 删除记 ROW_DELETE oplog。
- 附件读写 Service/Controller(跟进限 10 个/条应用层校验)。
- 统一审计切面自动写 `opportunity_oplog`(写入点驱动)——字段级审计切面是独立机制,随主表/子表写入接线。
- crm-dict 新增分组「跟进方式/跟进结果标签/商机资料类型」= 字典种子子票。

3
crm-opportunity/CONTEXT.md

@ -174,7 +174,8 @@ _Avoid_: 浏览(不算有效跟进)、编辑主表字段(不计)、上
## 待细化(后续票)
- 「移交」按钮原型位置、「取消暂缓/重启」独立页存否 —— 见 `issues/01` 的 To-verify,以产品口径为准。
- ~~商机主表字段~~ **票 03 已落地**(`Opportunity` 实体 + `OpportunityMapper`,三簇 + Amendment 1 + 乐观锁 version);子表(团队成员 / 方案卡 / 现场勘察 / 跟进 / 附件 / 操作日志)字段仍归后续票(07/08/09)
- ~~商机主表字段~~ **票 03 已落地**(`Opportunity` 实体 + `OpportunityMapper`,三簇 + Amendment 1 + 乐观锁 version);子表(方案卡票 07 已落 / 跟进·附件·工作计划·操作日志票 09 已落 / 现场勘察票 08 / 团队成员待后续)字段归各票
- ~~状态机迁移图~~ **票 04 已落地**(`OpportunityTransition` + sealed `OpportunityTransitionCmd` 九边(E1-E9,每带 allowedFromStatuses,无 owner guard)+ `executeRecycle` 批量回收(E10);CAS 两级口径:守卫前置 STATUS_NOT_ALLOWED / 0 行 CAS_FAIL,多起始态边(E6/E9)锚合法起始态集合,E9 另锚 owner;E5 锚点顺推纯函数;`OpportunityStatusHistory` 快照表 + Recorder;`OpportunityConvertPort` 三件套 inbound 端口(幂等含并发复查终态补丁));E9 team 表同步维护待 opportunity_team 结构定稿后接线(javadoc 注明);E7 重启不刷回收锚点为规格空白(PRD §3.2 注记 20260824,待产品拍板)。
- ~~商机公海规则~~ **票 05 已落地**(`OpportunityMaintenanceJob` + 待发通知表):crm-rule 侧实体/版本状态机服务/控制器/菜单种子 + 快照式匹配引擎 `OpportunityPoolRuleMatcher`(见 crm-rule CONTEXT 商机规则子域);本模块侧 `executeRecycle` 演进为 **ids 集合语义**(选择策略归 Job——各部门 recycle_days 不同无法单 cutoff 表达,迁移机制归状态机,按 id+推进中 CAS 复查静默跳过并发变更行)、`OpportunityMaintenanceJob` 单 `@Scheduled`(cron 占位符 `crm.opportunity.maintenance-cron` 默认每日 3 点)**先提醒后回收**串行(ADR-0019 对齐)+ 回收后待发提醒置已失效;`OpportunityPendingNotice` 实体 + 常量(UNIQUE 批次键 = plan_notify_time,幂等口径见 PRD §7.10 回填)。**已知缺口(code-review 20260824 登记)**:① §7.10 失效语义仅落地「回收」来源——领取(E1)/暂缓(E4/E5)/关闭(E6/E9)路径的置失效埋点待通知消费者票统一补(消费者本期 spin-out,误置「待发」无人消费、实际影响为零);② `allow_free_claim`/`allow_manual_pool` 本期仅可配置未生效,消费点随领取/抛公海入口票(票 10/11)接线;③ `invalidatePendingReminds` 按本轮候选全集近似置失效(含 CAS 静默跳过行),已声明由消费者补发时校验商机状态兜底。四视图查询与「我的关注」表(票 10)仍待后续。
- ~~商机子表族(跟进 / 附件 / 工作计划 / 操作日志)~~ **票 09 已落地**(§7.4–7.7 四张表结构 + Mapper 骨架):`OpportunityFollow`(跟进,软删+delete_key,无草稿态,支持删除不回退锚点,本期不做编辑)/ `OpportunityAttachment`(统一附件表,`biz_type`+`biz_id` 定位,`AttachmentBizType` 枚举 OPP/FOLLOW_UP/SITE_SURVEY/SCHEME_CARD,文件本体走 crm-file,软删+delete_key)/ `OpportunityWorkPlan`(工作计划**结构 only**,plan_status 0/1,精确态枚举与联动归 A1X 模块 spin-out,软删+delete_key)/ `OpportunityOplog`(字段级审计,**双维度** `OplogKind`(FIELD_CHANGE/ROW_ADD/ROW_DELETE)+ `OplogLogType`(7 类,SYSTEM_INTERNAL 记库不显),只增不删无 delete_key,带 biz_ref + snapshotJson)。**本票只落表结构 + Mapper + 枚举 + 冒烟单测**(对齐票 03 粒度);**接线待后续**:跟进读写 Service/Controller + 刷 `last_valid_follow_time`(票 05 联动)、附件读写、统一审计切面自动写 oplog(写入点驱动)均未接。crm-dict 新增分组「跟进方式/跟进结果标签/商机资料类型」待字典种子票。
- ~~`crm-rule` 从「线索规则」升级为「通用业务规则域」的 CONTEXT.md 改写~~ **票 05 已完成**(标题/首段升级 + 商机规则子域三词汇:商机公海规则/规则版本状态机/公海规则匹配引擎;线索子域内容不动)。

63
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityAttachment.java

@ -0,0 +1,63 @@
package com.crm.opportunity.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
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;
/**
* 统一附件子表 09 §7.5 / 原型 A3-1-1-1-5文件本体复用 crm-file
*
* <p>一张附件表 + {@code biz_type}+{@code biz_id} 定位业务对象不为每个子表各建附件表
* 07/08 的附件契约落点附件归属商机无孤立附件硬约束{@code biz_type=OPP}
* {@code biz_id}=opp_id 或空其余类型 {@code biz_id}=对应子表业务对象 id
* {@link com.crm.opportunity.domain.enums.AttachmentBizType}</p>
*
* <p>数量上限仅跟进附件限 10 /原型明画应用层校验其余 biz_type 不设业务上限
* crm-file 层统一管控grill 补拍 20260821本表存资料类型/说明/上传人/时间
* 文件本体走 crm-file软删 + {@code delete_key}对齐子表族通用约定</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity_attachment")
@Entity
@Table(name = "opportunity_attachment",
indexes = {
@Index(name = "idx_oa_opp", columnList = "opp_id"),
@Index(name = "idx_oa_biz", columnList = "biz_type,biz_id")
})
public class OpportunityAttachment extends BaseEntity {
@Comment("所属商机ID(opportunity.id,附件恒归属商机,无孤立附件)")
@Column(columnDefinition = "bigint not null comment '所属商机ID'")
private Long oppId;
@Comment("业务归属类型:OPP/FOLLOW_UP/SITE_SURVEY/SCHEME_CARD(AttachmentBizType)")
@Column(columnDefinition = "varchar(32) not null comment '业务归属类型'")
private String bizType;
@Comment("业务对象ID(跟进/勘察/方案卡 id;biz_type=OPP 时=opp_id 或空)")
@Column(columnDefinition = "bigint comment '业务对象ID'")
private Long bizId;
@Comment("资料类型字典 code(现场资料/设计清单·图纸/客户需求资料…,crm-dict「商机资料类型」分组)")
@Column(columnDefinition = "varchar(32) comment '资料类型字典code'")
private String materialType;
@Comment("文件ID(引用 crm-file 文件本体)")
@Column(columnDefinition = "bigint not null comment '文件ID(crm-file)'")
private Long fileId;
@Comment("附件说明(选填)")
@Column(columnDefinition = "varchar(500) comment '附件说明'")
private String remark;
@Comment("软删除复用键:存活时固定 0,逻辑删除时写为主键 id")
@Column(columnDefinition = "bigint not null default 0 comment '软删除复用键'")
private Long deleteKey;
}

75
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFollow.java

@ -0,0 +1,75 @@
package com.crm.opportunity.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
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.time.LocalDateTime;
/**
* 跟进记录子表 09 §7.4 / 原型 A3-1-1-1-6写跟进 A3-1-1-2-2
*
* <p>销售过程痕迹写跟进弹窗确认即校验 + 提交无草稿态异于线索反馈记录
* 保存跟进 刷新商机最近跟进 + {@code last_valid_follow_time}回收锚点刷新点之一
* 05 联动与阶段推进**不联动**写跟进不自动推阶段 06</p>
*
* <p>删除口径grill 拍板 20260821**支持删除**删除记一条 ROW_DELETE oplog
* {@code last_valid_follow_time} 删除后保持不变不回退重算写了又删刷锚点
* 最近跟进计算列按删除后最新留存行自然变化**编辑本期不做**写错以删除 + 补写修正
* 待确认清单 P2-9软删 + {@code delete_key} 复用键对齐子表族通用约定</p>
*
* <p>附件走统一附件表 {@code opportunity_attachment}{@code biz_type=FOLLOW_UP}最多 10 /</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity_follow")
@Entity
@Table(name = "opportunity_follow",
indexes = {
@Index(name = "idx_of_opp", columnList = "opp_id"),
@Index(name = "idx_of_customer", columnList = "customer_id")
})
public class OpportunityFollow extends BaseEntity {
@Comment("所属商机ID(opportunity.id)")
@Column(columnDefinition = "bigint not null comment '所属商机ID'")
private Long oppId;
@Comment("跟进内容(必填,长文本)")
@Column(columnDefinition = "text not null comment '跟进内容'")
private String followContent;
@Comment("跟进时间(必填,默认当前时间可改)")
@Column(columnDefinition = "datetime not null comment '跟进时间'")
private LocalDateTime followTime;
@Comment("跟进方式字典 code(电话沟通/上门拜访/微信对接/线上会议/展会沟通,crm-dict「跟进方式」分组)")
@Column(columnDefinition = "varchar(32) not null comment '跟进方式字典code'")
private String followWay;
@Comment("关联客户ID(必填,默认带出商机关联客户,引用 opportunity_customer.customer_id)")
@Column(columnDefinition = "bigint not null comment '关联客户ID'")
private Long customerId;
@Comment("客户联系人ID(选填,引用 A4 联系人,未建)")
@Column(columnDefinition = "bigint comment '客户联系人ID'")
private Long contactId;
@Comment("跟进结果标签字典 code(选填,暂无进展/需求确认/方案推进…,crm-dict「跟进结果标签」分组)")
@Column(columnDefinition = "varchar(32) comment '跟进结果标签字典code'")
private String resultTag;
@Comment("计划下次跟进时间(选填,不得早于本次跟进时间,应用层校验;可生成后续提醒/工作计划)")
@Column(columnDefinition = "datetime comment '计划下次跟进时间'")
private LocalDateTime nextFollowTime;
@Comment("软删除复用键:存活时固定 0,逻辑删除时写为主键 id")
@Column(columnDefinition = "bigint not null default 0 comment '软删除复用键'")
private Long deleteKey;
}

92
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityOplog.java

@ -0,0 +1,92 @@
package com.crm.opportunity.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
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.time.LocalDateTime;
/**
* 操作日志子表 09 §7.7 / 原型 A3-1-1-1-9字段级变更审计 + 双维度分类
*
* <p>异于线索 {@code lead_history}动作枚举 + JSON detail商机操作日志走**字段级审计模型**
* 实体对象 / 字段名 / 旧值 / 新值 / 操作说明 / 来源系统自动 vs 人工一条日志由两个正交
* 枚举描述grill 拍板 20260821{@code op_kind} 记录形态怎么存
* {@link com.crm.opportunity.domain.enums.OplogKind}+ {@code log_type} 业务类别
* 决定前端日志页是否展示 {@link com.crm.opportunity.domain.enums.OplogLogType}</p>
*
* <p>/不记口径P1-5 + grill默认全记 + 短黑名单排除技术字段 create_time/update_time/
* version无业务含义的系统自动刷新 last_valid_follow_time SYSTEM_INTERNAL 都不落
* 状态/阶段流转照记与专用快照表 {@code opportunity_status_history}/{@code opportunity_stage_history}
* 并存不重复快照=流转事实日志=统一审计视图**只增不删**审计轨迹 delete_key
* 普通用户只读支持按时间范围查询 + 导出写入点由统一审计切面驱动接线归后续</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity_oplog")
@Entity
@Table(name = "opportunity_oplog",
indexes = {
@Index(name = "idx_ool_opp_time", columnList = "opp_id,op_time"),
@Index(name = "idx_ool_log_type", columnList = "log_type")
})
public class OpportunityOplog extends BaseEntity {
@Comment("所属商机ID(opportunity.id)")
@Column(columnDefinition = "bigint not null comment '所属商机ID'")
private Long oppId;
@Comment("记录形态:FIELD_CHANGE/ROW_ADD/ROW_DELETE(OplogKind,决定 old/new/biz_ref 语义)")
@Column(columnDefinition = "varchar(32) not null comment '记录形态'")
private String opKind;
@Comment("业务类别:FIELD_CHANGE/STATUS_FLOW/STAGE_FLOW/ATTACHMENT/SCHEME_PUSH/ROW_CHANGE/SYSTEM_INTERNAL(OplogLogType,决定前端是否展示)")
@Column(columnDefinition = "varchar(32) not null comment '业务类别'")
private String logType;
@Comment("变更实体对象(哪张表/哪个业务对象变更)")
@Column(columnDefinition = "varchar(64) comment '变更实体对象'")
private String entityName;
@Comment("变更字段名(op_kind=FIELD_CHANGE 时有意义)")
@Column(columnDefinition = "varchar(64) comment '变更字段名'")
private String fieldName;
@Comment("旧值(ROW_ADD 时空)")
@Column(columnDefinition = "varchar(1000) comment '旧值'")
private String oldValue;
@Comment("新值(ROW_DELETE 时空)")
@Column(columnDefinition = "varchar(1000) comment '新值'")
private String newValue;
@Comment("被操作行业务标识(ROW_ADD/ROW_DELETE 时的可读标识,如客户名,配合快照摘要)")
@Column(columnDefinition = "varchar(200) comment '被操作行业务标识'")
private String bizRef;
@Comment("整行快照摘要 JSON(ROW_DELETE 保留被删行关键内容,供轨迹回读)")
@Column(columnDefinition = "text comment '整行快照摘要JSON'")
private String snapshotJson;
@Comment("操作说明")
@Column(columnDefinition = "varchar(500) comment '操作说明'")
private String opDesc;
@Comment("操作来源:SYSTEM 系统自动 / MANUAL 人工")
@Column(columnDefinition = "varchar(16) not null comment '操作来源:SYSTEM/MANUAL'")
private String opSource;
@Comment("操作时间")
@Column(columnDefinition = "datetime not null comment '操作时间'")
private LocalDateTime opTime;
@Comment("操作人ID(系统自动时可空)")
@Column(columnDefinition = "bigint comment '操作人ID'")
private Long opUserId;
}

58
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityWorkPlan.java

@ -0,0 +1,58 @@
package com.crm.opportunity.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
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.time.LocalDateTime;
/**
* 工作计划子表 09 §7.6 / 原型 A3-1-1-1-7**结构 only**
*
* <p>🅿 工作计划 = 独立业务模块 A1X本期挂起产品拍板 P1-4本商机模块**只保留子表结构与
* 归属契约**多条 / 归属商机 / 内容 / deadline / plan_status / finish_time**精确态枚举
* 状态机下次跟进时间生成工作计划的联动Tab 读中央模块** 全归 A1X 模块定不在本期决策</p>
*
* <p>{@code plan_status}未完成 0 / 已完成 1逾期 = 未完成且 {@code now > deadline}实时算不落库
* 剩余时间 deadline 实时算展示不落库软删 + {@code delete_key}对齐子表族通用约定</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity_work_plan")
@Entity
@Table(name = "opportunity_work_plan",
indexes = {
@Index(name = "idx_owp_opp", columnList = "opp_id")
})
public class OpportunityWorkPlan extends BaseEntity {
@Comment("所属商机ID(opportunity.id)")
@Column(columnDefinition = "bigint not null comment '所属商机ID'")
private Long oppId;
@Comment("计划内容")
@Column(columnDefinition = "varchar(1000) not null comment '计划内容'")
private String planContent;
@Comment("截止时间(判临期/逾期,逾期实时算不落库)")
@Column(columnDefinition = "datetime comment '截止时间'")
private LocalDateTime deadline;
@Comment("计划状态:0未完成 1已完成(逾期=未完成且now>deadline实时算;精确态枚举归A1X模块)")
@Column(columnDefinition = "tinyint not null default 0 comment '计划状态:0未完成 1已完成'")
private Integer planStatus;
@Comment("标记完成时间")
@Column(columnDefinition = "datetime comment '标记完成时间'")
private LocalDateTime finishTime;
@Comment("软删除复用键:存活时固定 0,逻辑删除时写为主键 id")
@Column(columnDefinition = "bigint not null default 0 comment '软删除复用键'")
private Long deleteKey;
}

33
crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/AttachmentBizType.java

@ -0,0 +1,33 @@
package com.crm.opportunity.domain.enums;
import com.crm.base.domain.enums.HasValueEnum;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 商机附件业务归属类型 09 §7.5 统一附件表 biz_type
*
* <p>一张 {@code opportunity_attachment} 表承载所有子表族附件 {@code biz_type}+{@code biz_id}
* 定位业务对象不为每个子表各建附件表 07/08 的附件契约落点附件恒归属商机
* 无孤立附件硬约束{@code biz_type=OPP} {@code biz_id} = opp_id 或空其余类型
* {@code biz_id} = 对应子表业务对象 id</p>
*/
@Getter
@RequiredArgsConstructor
public enum AttachmentBizType implements HasValueEnum<String> {
/** 商机通用资料(不挂具体子表,biz_id=opp_id 或空) */
OPP("OPP", "商机通用"),
/** 跟进记录附件(biz_id=opportunity_follow.id,最多 10 个/条,PRD §7.5) */
FOLLOW_UP("FOLLOW_UP", "跟进记录"),
/** 现场勘察附件(biz_id=勘察记录 id,票 08) */
SITE_SURVEY("SITE_SURVEY", "现场勘察"),
/** 方案卡附件(biz_id=opportunity_scheme_card.id,票 07) */
SCHEME_CARD("SCHEME_CARD", "方案卡");
private final String value;
private final String label;
}

30
crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogKind.java

@ -0,0 +1,30 @@
package com.crm.opportunity.domain.enums;
import com.crm.base.domain.enums.HasValueEnum;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 操作日志记录形态 09 §7.7 双维度模型之一怎么存
*
* <p> {@link OplogLogType}业务类别决定前端是否展示正交{@code op_kind} 描述一条
* 日志的物理形态字段级旧新值 / 整行新增 / 整行删除决定 {@code old_value}/{@code new_value}/
* {@code biz_ref} 哪些有意义商机操作日志走字段级审计模型异于线索 {@code lead_history}
* 的动作枚举 + JSON 模型PRD §7.7</p>
*/
@Getter
@RequiredArgsConstructor
public enum OplogKind implements HasValueEnum<String> {
/** 字段变更:有旧值与新值(field_name / old_value / new_value 均有意义) */
FIELD_CHANGE("FIELD_CHANGE", "字段变更"),
/** 整行新增:无旧值(如新增关联客户/团队成员,new_value 或快照摘要记内容) */
ROW_ADD("ROW_ADD", "整行新增"),
/** 整行删除:无新值,带 biz_ref 被删行业务标识 + 快照摘要 JSON(可读为「删除关联客户:上海XX公司」) */
ROW_DELETE("ROW_DELETE", "整行删除");
private final String value;
private final String label;
}

43
crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogLogType.java

@ -0,0 +1,43 @@
package com.crm.opportunity.domain.enums;
import com.crm.base.domain.enums.HasValueEnum;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 操作日志业务类别 09 §7.7 双维度模型之一决定前端日志页是否展示
*
* <p> {@link OplogKind}记录形态怎么存正交{@code log_type} 对应原型 line 1745
* 给出的行为类别决定该条日志在前端日志页是否可见 {@link #SYSTEM_INTERNAL}记库
* 前端过滤不显外均对终端用户可见枚举**不含浏览/关注**那两类为纯行为流水走物理硬删 +
* logback INFO不入 oplogPRD §7.8/7.9状态/阶段流转另有专用快照表
* {@code opportunity_status_history}/{@code opportunity_stage_history}与本通用审计并存不重复</p>
*/
@Getter
@RequiredArgsConstructor
public enum OplogLogType implements HasValueEnum<String> {
/** 字段变更(前端可见) */
FIELD_CHANGE("FIELD_CHANGE", "字段变更"),
/** 状态流转:opp_status 迁移(前端可见;快照另存 opportunity_status_history) */
STATUS_FLOW("STATUS_FLOW", "状态流转"),
/** 阶段流转:current_stage 切换(前端可见;快照另存 opportunity_stage_history) */
STAGE_FLOW("STAGE_FLOW", "阶段流转"),
/** 附件上传(前端可见) */
ATTACHMENT("ATTACHMENT", "附件上传"),
/** 方案推送(前端可见) */
SCHEME_PUSH("SCHEME_PUSH", "方案推送"),
/** 子表增删(前端可见;如关联客户/团队成员整行增删) */
ROW_CHANGE("ROW_CHANGE", "子表增删"),
/** 系统内部:有排查价值但不给终端用户看(记库、前端日志页过滤不显;如 executeRecycle 系统侧写) */
SYSTEM_INTERNAL("SYSTEM_INTERNAL", "系统内部");
private final String value;
private final String label;
}

9
crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityAttachmentMapper.java

@ -0,0 +1,9 @@
package com.crm.opportunity.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.opportunity.domain.entity.OpportunityAttachment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityAttachmentMapper extends CrmBaseMapper<OpportunityAttachment> {
}

9
crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFollowMapper.java

@ -0,0 +1,9 @@
package com.crm.opportunity.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.opportunity.domain.entity.OpportunityFollow;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityFollowMapper extends CrmBaseMapper<OpportunityFollow> {
}

9
crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityOplogMapper.java

@ -0,0 +1,9 @@
package com.crm.opportunity.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.opportunity.domain.entity.OpportunityOplog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityOplogMapper extends CrmBaseMapper<OpportunityOplog> {
}

9
crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityWorkPlanMapper.java

@ -0,0 +1,9 @@
package com.crm.opportunity.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.opportunity.domain.entity.OpportunityWorkPlan;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityWorkPlanMapper extends CrmBaseMapper<OpportunityWorkPlan> {
}

44
crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/OpportunityChildTablesEnumTest.java

@ -0,0 +1,44 @@
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;
/**
* 商机子表族枚举值域单测 09附件业务类型 + 操作日志双维度分类
*/
@DisplayName("商机子表族枚举(票 09)")
class OpportunityChildTablesEnumTest {
@Test
@DisplayName("附件业务类型 4 值:OPP/FOLLOW_UP/SITE_SURVEY/SCHEME_CARD")
void attachmentBizType_domain() {
assertThat(AttachmentBizType.values()).hasSize(4);
assertThat(AttachmentBizType.FOLLOW_UP.getValue()).isEqualTo("FOLLOW_UP");
assertThat(AttachmentBizType.SCHEME_CARD.getLabel()).isEqualTo("方案卡");
assertThat(EnumUtils.getByValue(AttachmentBizType.class, "SITE_SURVEY"))
.isEqualTo(AttachmentBizType.SITE_SURVEY);
assertThat(EnumUtils.getByValue(AttachmentBizType.class, "NOPE")).isNull();
}
@Test
@DisplayName("操作日志记录形态 3 值:FIELD_CHANGE/ROW_ADD/ROW_DELETE")
void oplogKind_domain() {
assertThat(OplogKind.values()).hasSize(3);
assertThat(OplogKind.ROW_DELETE.getValue()).isEqualTo("ROW_DELETE");
assertThat(EnumUtils.getByValue(OplogKind.class, "FIELD_CHANGE"))
.isEqualTo(OplogKind.FIELD_CHANGE);
}
@Test
@DisplayName("操作日志业务类别 7 值含 SYSTEM_INTERNAL,不含浏览/关注")
void oplogLogType_domain() {
assertThat(OplogLogType.values()).hasSize(7);
assertThat(EnumUtils.getByValue(OplogLogType.class, "SYSTEM_INTERNAL"))
.isEqualTo(OplogLogType.SYSTEM_INTERNAL);
assertThat(EnumUtils.getByValue(OplogLogType.class, "VIEW")).isNull();
assertThat(EnumUtils.getByValue(OplogLogType.class, "FOCUS")).isNull();
}
}

91
crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityChildTablesMapperTest.java

@ -0,0 +1,91 @@
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.OpportunityAttachment;
import com.crm.opportunity.domain.entity.OpportunityFollow;
import com.crm.opportunity.domain.entity.OpportunityOplog;
import com.crm.opportunity.domain.entity.OpportunityWorkPlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
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 骨架单测 09 §7.47.7跟进 / 附件 / 工作计划 / 操作日志
* <p>验证四张表 MyBatis-Plus 运行时表绑定表名 + 驼峰列映射 + 逻辑删除位
* 同构 {@link OpportunityMapperTest}跟进/附件/工作计划走软删deleted + delete_key
* 操作日志只增不删 deleted 逻辑删列但无 delete_key</p>
*/
@DisplayName("商机子表族 Mapper 骨架(票 09)")
class OpportunityChildTablesMapperTest {
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, OpportunityFollow.class);
TableInfoHelper.initTableInfo(assistant, OpportunityAttachment.class);
TableInfoHelper.initTableInfo(assistant, OpportunityWorkPlan.class);
TableInfoHelper.initTableInfo(assistant, OpportunityOplog.class);
}
@Test
@DisplayName("四 Mapper 均继承 CrmBaseMapper + 带 @Mapper 注解(可被扫描)")
void mappersWellFormed() {
for (Class<?> mapper : new Class<?>[]{
OpportunityFollowMapper.class, OpportunityAttachmentMapper.class,
OpportunityWorkPlanMapper.class, OpportunityOplogMapper.class}) {
assertThat(CrmBaseMapper.class).isAssignableFrom(mapper);
assertThat(mapper.getAnnotation(Mapper.class)).isNotNull();
}
}
@Test
@DisplayName("跟进表:绑定 opportunity_follow,driver 列映射 follow_way↔followWay + 软删复用键")
void followTableInfo() {
TableInfo info = TableInfoHelper.getTableInfo(OpportunityFollow.class);
assertThat(info.getTableName()).isEqualTo("opportunity_follow");
assertThat(info.getFieldList()).anyMatch(f ->
"follow_way".equals(f.getColumn()) && "followWay".equals(f.getProperty()));
assertThat(info.getLogicDeleteFieldInfo()).isNotNull();
assertThat(info.getFieldList()).anyMatch(f -> "delete_key".equals(f.getColumn()));
}
@Test
@DisplayName("附件表:绑定 opportunity_attachment,列映射 biz_type↔bizType / file_id↔fileId")
void attachmentTableInfo() {
TableInfo info = TableInfoHelper.getTableInfo(OpportunityAttachment.class);
assertThat(info.getTableName()).isEqualTo("opportunity_attachment");
assertThat(info.getFieldList()).anyMatch(f ->
"biz_type".equals(f.getColumn()) && "bizType".equals(f.getProperty()));
assertThat(info.getFieldList()).anyMatch(f ->
"file_id".equals(f.getColumn()) && "fileId".equals(f.getProperty()));
}
@Test
@DisplayName("工作计划表:绑定 opportunity_work_plan,列映射 plan_status↔planStatus")
void workPlanTableInfo() {
TableInfo info = TableInfoHelper.getTableInfo(OpportunityWorkPlan.class);
assertThat(info.getTableName()).isEqualTo("opportunity_work_plan");
assertThat(info.getFieldList()).anyMatch(f ->
"plan_status".equals(f.getColumn()) && "planStatus".equals(f.getProperty()));
}
@Test
@DisplayName("操作日志表:绑定 opportunity_oplog,双维度列 op_kind/log_type,只增不删无 delete_key")
void oplogTableInfo() {
TableInfo info = TableInfoHelper.getTableInfo(OpportunityOplog.class);
assertThat(info.getTableName()).isEqualTo("opportunity_oplog");
assertThat(info.getFieldList()).anyMatch(f ->
"op_kind".equals(f.getColumn()) && "opKind".equals(f.getProperty()));
assertThat(info.getFieldList()).anyMatch(f ->
"log_type".equals(f.getColumn()) && "logType".equals(f.getProperty()));
assertThat(info.getFieldList()).noneMatch(f -> "delete_key".equals(f.getColumn()));
}
}
Loading…
Cancel
Save