Browse Source
四视图查询层骨架(对称 crm-lead LeadViewQuery,一步到位用票 14 最终视图族): - OpportunityViewType 六视图:RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/ PUBLIC_POOL,fromValue 空/非法回落 MINE(票 14 默认视图口径) - OpportunityPageParam:viewType + 可筛选列字段池(票 14 确认点 3) - OpportunityDTO:主表字段 + 出参回显(字典/部门/区划名)+ 票 10 §二计算列 占位(节点停留/最近跟进/方案卡状态/方案预算/是否关注/入池前节点) 计算列聚合 + ViewQuery filter + Controller 待续。crm-opportunity 117→121 全绿。master
4 changed files with 337 additions and 0 deletions
@ -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)。 |
|||
* |
|||
* <p>基础字段 = 商机主表({@link Opportunity} §2);出参回显字段 = 票 10 §二列表列(计算列不冗余存、 |
|||
* 实时算/join)+ 字典/部门/人名快照名。{@code fromEntity} 只拷主表字段,回显字段由 ViewQuery 的 |
|||
* fillDisplayFields 二次填充。</p> |
|||
*/ |
|||
@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; |
|||
} |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
package com.crm.opportunity.domain.enums; |
|||
|
|||
import cn.hutool.core.util.StrUtil; |
|||
|
|||
/** |
|||
* 商机列表视图类型({@link com.crm.opportunity.domain.param.OpportunityPageParam#getViewType()} 取值域)。 |
|||
* |
|||
* <p>票 14 定稿的最终视图族(推翻票 10「单一我负责的」,一步到位免返工)。销售机会工作区内置四视图 |
|||
* (常用检索 Tab,顺序 = 最近访问/我负责的/我参与的/我关注的)+ 管理/公海两个独立 workspace:</p> |
|||
* <ul> |
|||
* <li>{@link #RECENT} 最近访问 — {@code opportunity_view_log} 命中当前用户,last_view_time 倒序取上限(票 10 P2-2 默认 50)</li> |
|||
* <li>{@link #MINE} 我负责的 — {@code owner_user_id = 当前用户}(票 10 个人口径)</li> |
|||
* <li>{@link #PARTICIPATED} 我参与的 — 团队成员且非负责人:{@code EXISTS(opportunity_team ... deleted=0) AND owner_user_id <> 当前用户}(票 14,与 MINE 互斥)</li> |
|||
* <li>{@link #FOLLOWED} 我关注的 — {@code opportunity_focus} 命中当前用户(票 10/14)</li> |
|||
* <li>{@link #MANAGE} 商机管理 — 按 {@code @DataScope} 部门天花板(复用 crm-auth,独立 workspace)</li> |
|||
* <li>{@link #PUBLIC_POOL} 商机公海 — {@code opp_status=1 且 owner_user_id 空} 逻辑视图(票 05,独立 workspace)</li> |
|||
* </ul> |
|||
* |
|||
* <p>个人四视图(RECENT/MINE/PARTICIPATED/FOLLOWED)叠加团队成员白名单可见性;MANAGE/PUBLIC_POOL 严格 |
|||
* 按 DataScope 不混白名单(PRD §1.3 生效边界)。前端以枚举名(大写)传入;空/未知值回落 {@link #DEFAULT}。</p> |
|||
*/ |
|||
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; |
|||
} |
|||
} |
|||
@ -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 视图族)。 |
|||
* |
|||
* <p>{@code viewType} 区分视图数据集(取值 {@link com.crm.opportunity.domain.enums.OpportunityViewType}, |
|||
* 空/非法回落 MINE)。其余字段 = 列表可筛选列(票 14 确认点 3「字段池」,自定义视图 filter_json 复用同一批列)。 |
|||
* {@code @DataScope}({@code Opportunity} 实体注解)经拦截器对 wrapper 自动追加部门天花板。</p> |
|||
*/ |
|||
@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<Integer> 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; |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue