Browse Source
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
10 changed files with 643 additions and 13 deletions
@ -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> |
|||
@ -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 簇 B1:bigint 业务外键,全生命周期不变)。 |
|||
* <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; |
|||
} |
|||
@ -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} tinyint:1 待领取(在公海) / 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; |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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 { |
|||
|
|||
/** |
|||
* 纯单元测试无 SqlSessionFactory,TableInfoHelper 初始化 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())); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue