Browse Source

feat(opportunity): 票 08 现场勘察子表结构落地(§7.3)

OpportunitySiteSurvey 实体 + Mapper 骨架(对齐票 03/09 粒度):
- 一商机多条保留历史、无状态机、不绑阶段(自由记录)
- 必填 surveyDate/engineerUserId/applyWay/surveySeq,选填 applyNo/surveyDesc
- 主要工程师引用 crm-auth SysUser(可跨部门选人,≠操作人),联系方式反查不冗余存
- 附件复用统一附件表 biz_type=SITE_SURVEY(票 09)
- 无唯一约束 → BaseEntity 逻辑删,不加 delete_key

测试:OpportunitySiteSurveyMapperTest,crm-opportunity 108→111 全绿。

接线待后续:勘察读写 Service/Controller、crm-dict 申请方式/勘察次数分组
master
luoweijian 2 weeks ago
parent
commit
73797fcd10
  1. 13
      .scratch/opportunity-module/issues/08-site-survey.md
  2. 2
      crm-opportunity/CONTEXT.md
  3. 68
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySiteSurvey.java
  4. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySiteSurveyMapper.java
  5. 56
      crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunitySiteSurveyMapperTest.java

13
.scratch/opportunity-module/issues/08-site-survey.md

@ -69,3 +69,16 @@ Blocked by: 03
- ✅ 勘察补充说明:**选填**(原型录入表单无 `*`,用户图上确认;P1-3 拍板,不加码必填)。 - ✅ 勘察补充说明:**选填**(原型录入表单无 `*`,用户图上确认;P1-3 拍板,不加码必填)。
- ⚠ 「申请方式」选项集(走 `crm-dict` 字典,具体值待产品)。 - ⚠ 「申请方式」选项集(走 `crm-dict` 字典,具体值待产品)。
- ⚠ 原型工程师/日期/单号均为样例,非验收值。 - ⚠ 原型工程师/日期/单号均为样例,非验收值。
---
## 落地记录(20260825)
**结构落地完成**(对齐票 03/09 粒度,§7.3):
- 实体 `OpportunitySiteSurvey`(`@Entity`+`@TableName`+继承 `BaseEntity`)+ `OpportunitySiteSurveyMapper`
- 必填:`surveyDate` / `engineerUserId` / `applyWay` / `surveySeq`;选填:`applyNo` / `surveyDesc`(以 grill 底稿为准,PRD §7.3 表中 apply_way/survey_seq 标「—」以底稿必填覆盖)。
- 无状态机、不绑阶段;无唯一约束→走 BaseEntity 逻辑删 `deleted`,**不加 delete_key**(复用键仅为唯一索引服务)。
- 附件复用统一附件表(`biz_type=SITE_SURVEY` + `biz_id`=本记录 id,票 09 已落)。
- 测试:`OpportunitySiteSurveyMapperTest`(表绑定+列映射+逻辑删),crm-opportunity 108→111 全绿。
**接线待后续**:勘察读写 Service/Controller(新增/列表/详情,工程师选人反查 SysUser 脱敏展示);crm-dict 新增「申请方式/勘察次数」分组 = 字典种子子票。

2
crm-opportunity/CONTEXT.md

@ -174,7 +174,7 @@ _Avoid_: 浏览(不算有效跟进)、编辑主表字段(不计)、上
## 待细化(后续票) ## 待细化(后续票)
- 「移交」按钮原型位置、「取消暂缓/重启」独立页存否 —— 见 `issues/01` 的 To-verify,以产品口径为准。 - 「移交」按钮原型位置、「取消暂缓/重启」独立页存否 —— 见 `issues/01` 的 To-verify,以产品口径为准。
- ~~商机主表字段~~ **票 03 已落地**(`Opportunity` 实体 + `OpportunityMapper`,三簇 + Amendment 1 + 乐观锁 version);子表(方案卡票 07 已落 / 跟进·附件·工作计划·操作日志票 09 已落 / 现场勘察票 08 / 团队成员待后续)字段归各票。 - ~~商机主表字段~~ **票 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,待产品拍板)。 - ~~状态机迁移图~~ **票 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)仍待后续。 - ~~商机公海规则~~ **票 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 新增分组「跟进方式/跟进结果标签/商机资料类型」待字典种子票。 - ~~商机子表族(跟进 / 附件 / 工作计划 / 操作日志)~~ **票 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 新增分组「跟进方式/跟进结果标签/商机资料类型」待字典种子票。

68
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySiteSurvey.java

@ -0,0 +1,68 @@
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.LocalDate;
/**
* 现场勘察子表 08 §7.3 / 原型 A3-1-1-1-4 列表A3-1-1-2-10 新增弹窗
*
* <p>记录商机某次到客户现场勘察的**结果单据**现场条件/客户需求/安装环境/工程师建议 + 附件
* 一个商机可多条勘察记录多次勘察保留历史不覆盖新增多条区分**无状态机**填完即完整记录
* 异于方案卡**不绑阶段节点**自由记录不联动轴 2</p>
*
* <p>主要工程师从 {@code crm-auth} 组织架构选{@code engineer_user_id} 引用 SysUser非自由文本
* 可跨部门查询选人工程师是被选中的执行人 操作人当前登录销售/管理员列表联系方式
* engineer_user_id 反查 SysUser 电话脱敏展示不冗余存附件走统一附件表
* {@code opportunity_attachment}{@code biz_type=SITE_SURVEY}{@code biz_id}=本记录 id 09</p>
*
* <p>删除留痕走 BaseEntity 逻辑删{@code deleted}无唯一约束故不需 delete_key 复用键
* 对齐子表族约定delete_key 仅为唯一索引复用服务</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity_site_survey")
@Entity
@Table(name = "opportunity_site_survey",
indexes = {
@Index(name = "idx_oss_opp", columnList = "opp_id"),
@Index(name = "idx_oss_engineer", columnList = "engineer_user_id")
})
public class OpportunitySiteSurvey extends BaseEntity {
@Comment("所属商机ID(opportunity.id)")
@Column(columnDefinition = "bigint not null comment '所属商机ID'")
private Long oppId;
@Comment("现场勘察日期(必填)")
@Column(columnDefinition = "date not null comment '现场勘察日期'")
private LocalDate surveyDate;
@Comment("主要工程师用户ID(必填,引用 crm-auth SysUser,可跨部门选人;列表反查姓名/部门/脱敏电话)")
@Column(columnDefinition = "bigint not null comment '主要工程师用户ID'")
private Long engineerUserId;
@Comment("申请方式字典 code(工单申请/线下申请…,crm-dict「申请方式」分组)")
@Column(columnDefinition = "varchar(32) not null comment '申请方式字典code'")
private String applyWay;
@Comment("勘察次数字典 code(第一次/第二次/第三次/三次以上四档,crm-dict「勘察次数」分组,非自增,P1-2)")
@Column(columnDefinition = "varchar(32) not null comment '勘察次数字典code'")
private String surveySeq;
@Comment("申请单号(选填,人工录入,外部勘察申请流程未对接、线下拿到后补录)")
@Column(columnDefinition = "varchar(64) comment '申请单号'")
private String applyNo;
@Comment("勘察补充说明(选填,长文本:现场环境/客户需求/安装条件/设备数量/特殊要求/工程师建议)")
@Column(columnDefinition = "text comment '勘察补充说明'")
private String surveyDesc;
}

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

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

56
crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunitySiteSurveyMapperTest.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.OpportunitySiteSurvey;
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 骨架单测 08 §7.3
* <p>验证 MyBatis-Plus 表绑定 + 驼峰列映射无唯一约束故走 BaseEntity 逻辑删deleted
* delete_key 复用键</p>
*/
@DisplayName("现场勘察 Mapper 骨架 OpportunitySiteSurveyMapper(票 08)")
class OpportunitySiteSurveyMapperTest {
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, OpportunitySiteSurvey.class);
}
@Test
@DisplayName("继承 CrmBaseMapper + @Mapper 注解(可被扫描注册)")
void mapperWellFormed() {
assertThat(CrmBaseMapper.class).isAssignableFrom(OpportunitySiteSurveyMapper.class);
assertThat(OpportunitySiteSurveyMapper.class.getAnnotation(Mapper.class)).isNotNull();
}
@Test
@DisplayName("绑定 opportunity_site_survey,列映射 engineer_user_id↔engineerUserId / survey_seq↔surveySeq")
void tableInfo_resolvesSiteSurvey() {
TableInfo info = TableInfoHelper.getTableInfo(OpportunitySiteSurvey.class);
assertThat(info.getTableName()).isEqualTo("opportunity_site_survey");
assertThat(info.getFieldList()).anyMatch(f ->
"engineer_user_id".equals(f.getColumn()) && "engineerUserId".equals(f.getProperty()));
assertThat(info.getFieldList()).anyMatch(f ->
"survey_seq".equals(f.getColumn()) && "surveySeq".equals(f.getProperty()));
}
@Test
@DisplayName("逻辑删:BaseEntity deleted 生效,无 delete_key(无唯一约束不需复用键)")
void logicDelete_noDeleteKey() {
TableInfo info = TableInfoHelper.getTableInfo(OpportunitySiteSurvey.class);
assertThat(info.getLogicDeleteFieldInfo()).isNotNull();
assertThat(info.getFieldList()).noneMatch(f -> "delete_key".equals(f.getColumn()));
}
}
Loading…
Cancel
Save