diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityDTO.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityDTO.java new file mode 100644 index 0000000..9ddab1c --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityDTO.java @@ -0,0 +1,159 @@ +package com.crm.opportunity.domain.dto; + +import com.crm.base.domain.dto.BaseDTO; +import com.crm.opportunity.domain.entity.Opportunity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 商机传输对象(写入参 + 出参双向,ADR-0017 Route A)。 + * + *

基础字段 = 商机主表({@link Opportunity} §2);出参回显字段 = 票 10 §二列表列(计算列不冗余存、 + * 实时算/join)+ 字典/部门/人名快照名。{@code fromEntity} 只拷主表字段,回显字段由 ViewQuery 的 + * fillDisplayFields 二次填充。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OpportunityDTO extends BaseDTO { + + // -------- 业务字段 -------- + + @Schema(description = "商机名称") + private String oppName; + + @Schema(description = "商机来源字典 code") + private String oppSource; + + @Schema(description = "商机类型字典 code") + private String oppType; + + @Schema(description = "行业字典 code") + private String industryCode; + + @Schema(description = "招标形式字典 code") + private String bidForm; + + @Schema(description = "项目属地字典 code") + private String localityType; + + @Schema(description = "甲方是否明确 0/1") + private Integer partyAClear; + + @Schema(description = "甲方名称") + private String partyA; + + @Schema(description = "省份国标 code") + private String provinceCode; + + @Schema(description = "市/区国标 code") + private String cityCode; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "项目金额(列表展示为「预计金额」)") + private BigDecimal projectAmount; + + // -------- 归属 -------- + + @Schema(description = "负责人ID") + private Long ownerUserId; + + @Schema(description = "负责人姓名快照") + private String ownerNameSnapshot; + + @Schema(description = "负责人部门ID快照") + private Long ownerDeptId; + + @Schema(description = "进入公海原因字典 code") + private String poolReason; + + // -------- 两条正交轴 -------- + + @Schema(description = "商机状态(轴1:1待领取/2推进中/3暂缓中/4已关闭/5已转项目)") + private Integer oppStatus; + + @Schema(description = "当前阶段节点ID(轴2)") + private Long currentStageId; + + @Schema(description = "阶段模板版本号冗余") + private String stageTemplateVersion; + + // -------- 来源线索快照 -------- + + @Schema(description = "来源线索ID") + private Long sourceLeadId; + + // -------- 主要意向客户冗余 -------- + + @Schema(description = "主要意向客户ID") + private Long primaryCustomerId; + + @Schema(description = "主客户名快照(列表「意向客户」列)") + private String primaryCustomerNameSnapshot; + + @Schema(description = "领取时刻") + private LocalDateTime claimTime; + + @Schema(description = "最近有效跟进时间") + private LocalDateTime lastValidFollowTime; + + // -------- 出参回显:字典/部门/区划名 -------- + + @Schema(description = "商机来源名称(出参回显,字典快照)") + private String oppSourceName; + + @Schema(description = "行业名称(出参回显,字典快照)") + private String industryName; + + @Schema(description = "省份名称(出参回显)") + private String provinceName; + + @Schema(description = "市/区名称(出参回显)") + private String cityName; + + @Schema(description = "负责人部门名称(出参回显)") + private String ownerDeptName; + + // -------- 出参回显:票 10 §二 计算列(实时算/join,不冗余存) -------- + + @Schema(description = "当前工作节点名称(出参回显,= current_stage_id 节点名,票 06)") + private String currentStageName; + + @Schema(description = "节点停留时间(天,出参回显,= 今 − 进当前节点日期,opportunity_stage_history 最新 to_time)") + private Long stageStayDays; + + @Schema(description = "下一节点名称(出参回显,= 模板中当前节点下一 seq_no 节点,展示用建议下一步)") + private String nextStageName; + + @Schema(description = "最近跟进时间(出参回显,= 最新 opportunity_follow.create_time,票 09;无则空)") + private LocalDateTime lastFollowTime; + + @Schema(description = "方案卡状态(出参回显,= 主要意向客户那张卡 card_status,票 07/13;无主客户卡=未创建0)") + private Integer schemeCardStatus; + + @Schema(description = "方案预算(出参回显,= 主要意向客户那张卡 scheme_budget,票 07;无则空)") + private BigDecimal schemeBudget; + + @Schema(description = "当前用户是否已关注(出参回显,opportunity_focus)") + private Boolean followed; + + @Schema(description = "入池前工作节点名称(出参回显,公海视图专属,票 04/05)") + private String poolStageNameSnapshot; + + public static OpportunityDTO fromEntity(Opportunity entity) { + OpportunityDTO dto = new OpportunityDTO(); + org.springframework.beans.BeanUtils.copyProperties(entity, dto); + return dto; + } + + public Opportunity toEntity() { + Opportunity entity = new Opportunity(); + org.springframework.beans.BeanUtils.copyProperties(this, entity); + return entity; + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OpportunityViewType.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OpportunityViewType.java new file mode 100644 index 0000000..7f289d8 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OpportunityViewType.java @@ -0,0 +1,46 @@ +package com.crm.opportunity.domain.enums; + +import cn.hutool.core.util.StrUtil; + +/** + * 商机列表视图类型({@link com.crm.opportunity.domain.param.OpportunityPageParam#getViewType()} 取值域)。 + * + *

票 14 定稿的最终视图族(推翻票 10「单一我负责的」,一步到位免返工)。销售机会工作区内置四视图 + * (常用检索 Tab,顺序 = 最近访问/我负责的/我参与的/我关注的)+ 管理/公海两个独立 workspace:

+ * + * + *

个人四视图(RECENT/MINE/PARTICIPATED/FOLLOWED)叠加团队成员白名单可见性;MANAGE/PUBLIC_POOL 严格 + * 按 DataScope 不混白名单(PRD §1.3 生效边界)。前端以枚举名(大写)传入;空/未知值回落 {@link #DEFAULT}。

+ */ +public enum OpportunityViewType { + + RECENT, + MINE, + PARTICIPATED, + FOLLOWED, + MANAGE, + PUBLIC_POOL; + + /** 默认视图:无 viewType 或非法值回落「我负责的」(票 14 定义 3:默认视图未设 → mine)。 */ + public static final OpportunityViewType DEFAULT = MINE; + + /** 把前端字符串解析为视图类型;空白或无法识别回落 {@link #DEFAULT}。 */ + public static OpportunityViewType fromValue(String value) { + if (StrUtil.isBlank(value)) { + return DEFAULT; + } + for (OpportunityViewType type : values()) { + if (type.name().equals(value)) { + return type; + } + } + return DEFAULT; + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java new file mode 100644 index 0000000..47102b5 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java @@ -0,0 +1,59 @@ +package com.crm.opportunity.domain.param; + +import com.crm.base.domain.param.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 商机分页查询参数(票 10 四视图 + 票 14 视图族)。 + * + *

{@code viewType} 区分视图数据集(取值 {@link com.crm.opportunity.domain.enums.OpportunityViewType}, + * 空/非法回落 MINE)。其余字段 = 列表可筛选列(票 14 确认点 3「字段池」,自定义视图 filter_json 复用同一批列)。 + * {@code @DataScope}({@code Opportunity} 实体注解)经拦截器对 wrapper 自动追加部门天花板。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OpportunityPageParam extends BaseParam { + + @Schema(description = "视图类型(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL,空/非法回落 MINE)") + private String viewType; + + @Schema(description = "商机状态筛选(多值,支撑复合卡片下钻)") + private List statusIn; + + @Schema(description = "商机来源字典 code 筛选") + private String oppSource; + + @Schema(description = "商机类型字典 code 筛选") + private String oppType; + + @Schema(description = "行业字典 code 筛选") + private String industryCode; + + @Schema(description = "招标形式字典 code 筛选") + private String bidForm; + + @Schema(description = "项目属地字典 code 筛选") + private String localityType; + + @Schema(description = "省份国标 code 筛选") + private String provinceCode; + + @Schema(description = "市/区国标 code 筛选") + private String cityCode; + + @Schema(description = "归属部门ID筛选") + private Long ownerDeptId; + + @Schema(description = "负责人ID筛选") + private Long ownerUserId; + + @Schema(description = "进入公海原因字典 code 筛选(公海视图)") + private String poolReason; + + @Schema(description = "当前阶段节点ID筛选") + private Long currentStageId; +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/query/OpportunityViewQuerySkeletonTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/query/OpportunityViewQuerySkeletonTest.java new file mode 100644 index 0000000..beeefd2 --- /dev/null +++ b/crm-opportunity/src/test/java/com/crm/opportunity/query/OpportunityViewQuerySkeletonTest.java @@ -0,0 +1,73 @@ +package com.crm.opportunity.query; + +import com.crm.opportunity.domain.dto.OpportunityDTO; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.domain.enums.OpportunityViewType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 商机读侧骨架单测(票 10):视图类型解析 + DTO 双向转换。 + */ +@DisplayName("商机读侧骨架 ViewType + DTO(票 10)") +class OpportunityViewQuerySkeletonTest { + + @Test + @DisplayName("OpportunityViewType 六视图 + 默认 MINE") + void viewType_domain() { + assertThat(OpportunityViewType.values()).hasSize(6); + assertThat(OpportunityViewType.DEFAULT).isEqualTo(OpportunityViewType.MINE); + } + + @Test + @DisplayName("fromValue:命中大写枚举名,空/非法回落 MINE") + void viewType_fromValue() { + assertThat(OpportunityViewType.fromValue("PARTICIPATED")) + .isEqualTo(OpportunityViewType.PARTICIPATED); + assertThat(OpportunityViewType.fromValue("PUBLIC_POOL")) + .isEqualTo(OpportunityViewType.PUBLIC_POOL); + assertThat(OpportunityViewType.fromValue(null)).isEqualTo(OpportunityViewType.MINE); + assertThat(OpportunityViewType.fromValue(" ")).isEqualTo(OpportunityViewType.MINE); + assertThat(OpportunityViewType.fromValue("nope")).isEqualTo(OpportunityViewType.MINE); + } + + @Test + @DisplayName("DTO fromEntity 拷主表字段,回显/计算列不来自 entity") + void dto_fromEntity_copiesBaseFields() { + Opportunity e = new Opportunity(); + e.setOppName("XX智能化项目"); + e.setOwnerUserId(1001L); + e.setOppStatus(2); + e.setProjectAmount(new BigDecimal("128000.00")); + e.setPrimaryCustomerNameSnapshot("上海XX公司"); + + OpportunityDTO dto = OpportunityDTO.fromEntity(e); + + assertThat(dto.getOppName()).isEqualTo("XX智能化项目"); + assertThat(dto.getOwnerUserId()).isEqualTo(1001L); + assertThat(dto.getOppStatus()).isEqualTo(2); + assertThat(dto.getProjectAmount()).isEqualByComparingTo("128000.00"); + assertThat(dto.getPrimaryCustomerNameSnapshot()).isEqualTo("上海XX公司"); + // 计算列/回显未填充 + assertThat(dto.getStageStayDays()).isNull(); + assertThat(dto.getSchemeCardStatus()).isNull(); + assertThat(dto.getFollowed()).isNull(); + } + + @Test + @DisplayName("DTO toEntity 反向拷回") + void dto_toEntity_roundTrip() { + OpportunityDTO dto = new OpportunityDTO(); + dto.setOppName("回写商机"); + dto.setOppStatus(3); + + Opportunity e = dto.toEntity(); + + assertThat(e.getOppName()).isEqualTo("回写商机"); + assertThat(e.getOppStatus()).isEqualTo(3); + } +}