From 825cb2cf9a3e71c19fb7b4b9591af2f8eb7e7926 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 11:08:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E7=A5=A8=2009=20=E5=AD=90?= =?UTF-8?q?=E8=A1=A8=E6=97=8F=E7=BB=93=E6=9E=84=E8=90=BD=E5=9C=B0=EF=BC=88?= =?UTF-8?q?=E8=B7=9F=E8=BF=9B/=E9=99=84=E4=BB=B6/=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E8=AE=A1=E5=88=92/=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 跟进方式/结果标签/资料类型分组 --- ...09-child-tables-followup-attachment-log.md | 17 ++++ crm-opportunity/CONTEXT.md | 3 +- .../domain/entity/OpportunityAttachment.java | 63 +++++++++++++ .../domain/entity/OpportunityFollow.java | 75 +++++++++++++++ .../domain/entity/OpportunityOplog.java | 92 +++++++++++++++++++ .../domain/entity/OpportunityWorkPlan.java | 58 ++++++++++++ .../domain/enums/AttachmentBizType.java | 33 +++++++ .../opportunity/domain/enums/OplogKind.java | 30 ++++++ .../domain/enums/OplogLogType.java | 43 +++++++++ .../mapper/OpportunityAttachmentMapper.java | 9 ++ .../mapper/OpportunityFollowMapper.java | 9 ++ .../mapper/OpportunityOplogMapper.java | 9 ++ .../mapper/OpportunityWorkPlanMapper.java | 9 ++ .../enums/OpportunityChildTablesEnumTest.java | 44 +++++++++ .../OpportunityChildTablesMapperTest.java | 91 ++++++++++++++++++ 15 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityAttachment.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFollow.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityOplog.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityWorkPlan.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/AttachmentBizType.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogKind.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogLogType.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityAttachmentMapper.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFollowMapper.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityOplogMapper.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityWorkPlanMapper.java create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/OpportunityChildTablesEnumTest.java create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityChildTablesMapperTest.java diff --git a/.scratch/opportunity-module/issues/09-child-tables-followup-attachment-log.md b/.scratch/opportunity-module/issues/09-child-tables-followup-attachment-log.md index c6e18a3..1480533 100644 --- a/.scratch/opportunity-module/issues/09-child-tables-followup-attachment-log.md +++ b/.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 新增分组「跟进方式/跟进结果标签/商机资料类型」= 字典种子子票。 diff --git a/crm-opportunity/CONTEXT.md b/crm-opportunity/CONTEXT.md index 5bd4346..7fedd55 100644 --- a/crm-opportunity/CONTEXT.md +++ b/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 已完成**(标题/首段升级 + 商机规则子域三词汇:商机公海规则/规则版本状态机/公海规则匹配引擎;线索子域内容不动)。 diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityAttachment.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityAttachment.java new file mode 100644 index 0000000..b7de395 --- /dev/null +++ b/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) + * + *

一张附件表 + {@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})。

+ * + *

数量上限:仅跟进附件限 10 个/条(原型明画,应用层校验);其余 biz_type 不设业务上限, + * 由 crm-file 层统一管控(grill 补拍 20260821)。本表存资料类型/说明/上传人/时间, + * 文件本体走 crm-file。软删 + {@code delete_key}(对齐子表族通用约定)。

+ */ +@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; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFollow.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFollow.java new file mode 100644 index 0000000..0d8202d --- /dev/null +++ b/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) + * + *

销售过程痕迹:写跟进弹窗「确认」即校验 + 提交,无草稿态(异于线索反馈记录)。 + * 保存跟进 → 刷新商机「最近跟进」 + {@code last_valid_follow_time}(回收锚点刷新点之一, + * 票 05 联动)。与阶段推进**不联动**(写跟进不自动推阶段,票 06)。

+ * + *

删除口径(grill 拍板 20260821):**支持删除**,删除记一条 ROW_DELETE oplog;但 + * {@code last_valid_follow_time} 删除后保持不变(不回退重算,防「写了又删」刷锚点); + * 「最近跟进」计算列按删除后最新留存行自然变化。**编辑本期不做**(写错以删除 + 补写修正, + * 待确认清单 P2-9)。软删 + {@code delete_key} 复用键(对齐子表族通用约定)。

+ * + *

附件走统一附件表 {@code opportunity_attachment}({@code biz_type=FOLLOW_UP},最多 10 个/条)。

+ */ +@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; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityOplog.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityOplog.java new file mode 100644 index 0000000..f4e84cb --- /dev/null +++ b/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):字段级变更审计 + 双维度分类 + * + *

异于线索 {@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})。

+ * + *

记/不记口径(P1-5 + grill):默认全记 + 短黑名单排除(技术字段 create_time/update_time/ + * version、无业务含义的系统自动刷新 last_valid_follow_time → 连 SYSTEM_INTERNAL 都不落)。 + * 状态/阶段流转照记,与专用快照表 {@code opportunity_status_history}/{@code opportunity_stage_history} + * 并存不重复(快照=流转事实,日志=统一审计视图)。**只增不删**(审计轨迹,无 delete_key); + * 普通用户只读,支持按时间范围查询 + 导出。写入点由统一审计切面驱动(接线归后续)。

+ */ +@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; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityWorkPlan.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityWorkPlan.java new file mode 100644 index 0000000..248c714 --- /dev/null +++ b/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**) + * + *

🅿️ 工作计划 = 独立业务模块 A1X,本期挂起(产品拍板 P1-4)。本商机模块**只保留子表结构与 + * 归属契约**(多条 / 归属商机 / 内容 / deadline / plan_status / finish_time);**精确态枚举、 + * 状态机、与「下次跟进时间生成工作计划」的联动、Tab 读中央模块** 全归 A1X 模块定,不在本期决策。

+ * + *

{@code plan_status}:未完成 0 / 已完成 1;逾期 = 未完成且 {@code now > deadline},实时算不落库; + * 「剩余时间」由 deadline 实时算展示不落库。软删 + {@code delete_key}(对齐子表族通用约定)。

+ */ +@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; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/AttachmentBizType.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/AttachmentBizType.java new file mode 100644 index 0000000..82b2b38 --- /dev/null +++ b/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) + * + *

一张 {@code opportunity_attachment} 表承载所有子表族附件,靠 {@code biz_type}+{@code biz_id} + * 定位业务对象,不为每个子表各建附件表(票 07/08 的附件契约落点)。附件恒归属商机 + * (无孤立附件硬约束):{@code biz_type=OPP} 时 {@code biz_id} = opp_id 或空,其余类型 + * {@code biz_id} = 对应子表业务对象 id。

+ */ +@Getter +@RequiredArgsConstructor +public enum AttachmentBizType implements HasValueEnum { + + /** 商机通用资料(不挂具体子表,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; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogKind.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogKind.java new file mode 100644 index 0000000..cbfa37d --- /dev/null +++ b/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 双维度模型之一,「怎么存」) + * + *

与 {@link OplogLogType}(业务类别,「决定前端是否展示」)正交:{@code op_kind} 描述一条 + * 日志的物理形态——字段级旧新值 / 整行新增 / 整行删除,决定 {@code old_value}/{@code new_value}/ + * {@code biz_ref} 哪些有意义。商机操作日志走字段级审计模型,异于线索 {@code lead_history} + * 的动作枚举 + JSON 模型(PRD §7.7)。

+ */ +@Getter +@RequiredArgsConstructor +public enum OplogKind implements HasValueEnum { + + /** 字段变更:有旧值与新值(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; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogLogType.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OplogLogType.java new file mode 100644 index 0000000..60d0a29 --- /dev/null +++ b/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 双维度模型之一,「决定前端日志页是否展示」) + * + *

与 {@link OplogKind}(记录形态,「怎么存」)正交:{@code log_type} 对应原型 line 1745 + * 给出的行为类别,决定该条日志在前端日志页是否可见。除 {@link #SYSTEM_INTERNAL}(记库、 + * 前端过滤不显)外均对终端用户可见。枚举**不含浏览/关注**——那两类为纯行为流水,走物理硬删 + + * logback INFO,不入 oplog(PRD §7.8/7.9)。状态/阶段流转另有专用快照表 + * {@code opportunity_status_history}/{@code opportunity_stage_history},与本通用审计并存不重复。

+ */ +@Getter +@RequiredArgsConstructor +public enum OplogLogType implements HasValueEnum { + + /** 字段变更(前端可见) */ + 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; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityAttachmentMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityAttachmentMapper.java new file mode 100644 index 0000000..108798d --- /dev/null +++ b/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 { +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFollowMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFollowMapper.java new file mode 100644 index 0000000..7ddbb7f --- /dev/null +++ b/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 { +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityOplogMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityOplogMapper.java new file mode 100644 index 0000000..84356c0 --- /dev/null +++ b/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 { +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityWorkPlanMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityWorkPlanMapper.java new file mode 100644 index 0000000..c2bea4a --- /dev/null +++ b/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 { +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/OpportunityChildTablesEnumTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/OpportunityChildTablesEnumTest.java new file mode 100644 index 0000000..e00d8fa --- /dev/null +++ b/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(); + } +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityChildTablesMapperTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityChildTablesMapperTest.java new file mode 100644 index 0000000..aa62423 --- /dev/null +++ b/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.4–7.7:跟进 / 附件 / 工作计划 / 操作日志)。 + *

验证四张表 MyBatis-Plus 运行时表绑定(表名 + 驼峰列映射 + 逻辑删除位), + * 同构 {@link OpportunityMapperTest}。跟进/附件/工作计划走软删(deleted + delete_key), + * 操作日志只增不删(有 deleted 逻辑删列但无 delete_key)。

+ */ +@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())); + } +}