diff --git a/.scratch/opportunity-module/map.md b/.scratch/opportunity-module/map.md index d973bbe..a286ff7 100644 --- a/.scratch/opportunity-module/map.md +++ b/.scratch/opportunity-module/map.md @@ -30,7 +30,7 @@ | 11 | createCmd customerId | ⛔ 卡 A4 | | 12 | 商机侧新增入口关联线索校验 | ⛔ 实质卡(建商机入口 Service 未建=依赖票 02/11 → A4) | | 13 | 商机-客户关联子表 | ⚠ 部分(实体 `OpportunityCustomer`+约束已落;读写入口/下拉选客户卡 A4) | -| 14 | 自定义视图 + 内置视图族 | 🔨 底座已落(team `participated` / focus `followed` / view_log `recent` 三表 + `TeamMemberPermission` 枚举已建);**视图组织 + crm-preference 自定义视图待开工**(依赖 10,接在 10 之后) | +| 14 | 自定义视图 + 内置视图族 | ✅ 主体已落(底座三表 team/focus/view_log + `TeamMemberPermission` 枚举;**内置六视图**在 crm-opportunity 枚举/ViewQuery 硬编码,票 10 主体已落;**自定义视图 = crm-preference 平台能力已落**:`UserSavedView` 实体 + Mapper + `SavedView`/`SavedViewCondition` DTO + `SavedViewService` 契约 list/save/delete/setDefault + Impl(默认视图单值互斥) + Controller `/api/preference/view` + 16 单测);**剩商机侧接线尾巴**:销售机会工作区读默认视图 / 把 filter_json 条件翻译成 OpportunityPageParam 过滤(字段池 = 票 10 PageParam 可筛选列,依赖票 10 回显尾巴一起收) | **前沿小结**:商机模块**结构层已全部落地**(主表 + 状态机 + 三族规则 + 六子表 + 视图数据源三表 team/focus/view_log)。剩余:① **票 10 查询层 + 票 14 视图组织/自定义视图 = 可独立开工的读侧大票(不卡 A4)** → 建议每票清上下文单独 `/implement`(底座表已备);② **票 02/11/12/13 读写入口 = 卡 A4 客户模块**(建商机核心逻辑需写关联客户主标记子表 customer_id),挂起等 A4。 @@ -87,7 +87,7 @@ -- **自定义视图实现落地(票 14 传出)** — `crm-preference` 新契约方法签名 `list/save/delete/setDefault` + `user_saved_view` 表 DDL;商机列表**可筛选列字段池清单**(字段池归商机模块,实现阶段列全)。均为实现阶段工作,本 spec-only effort 不展开。 +- **自定义视图实现落地(票 14 传出)** — ✅ **crm-preference 侧已落**:`user_saved_view` 表(`UserSavedView` 实体,uk(user,scope,view_id))+ 契约 `list/save/delete/setDefault`(Impl 默认视图单值互斥 + 16 单测)+ Controller `/api/preference/view`。**剩商机侧接线尾巴**:销售机会工作区进入时读默认视图、把 `filter_json` 条件列表翻译成 `OpportunityPageParam` 过滤(可筛选列字段池 = 票 10 `OpportunityPageParam` 已实现的列)—— 与票 10 回显尾巴(字典名 / 节点名)一起收。 - **客户关联子表的形态** — 已钉死为 **票 13**(见下 Decisions);仅“下拉选客户”数据源 + 客户联系人是否本子表冗余仍挂 **A4 客户模块(未建)** 边界(票 11 占位)。 - **商机批量操作与统计接口** — 对称线索 G1–G5(批量领取/分配/关注、列表统计卡片)。等主表 + 四视图定稿后再切成票。 diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java index dde0a87..7e0e41a 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java @@ -4,9 +4,13 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.crm.auth.domain.entity.SysDept; +import com.crm.auth.service.ISysDeptService; import com.crm.base.domain.result.PageResult; import com.crm.base.security.SecurityUtils; import com.crm.base.utils.PageConverter; +import com.crm.dict.domain.entity.DictItem; +import com.crm.dict.service.DictQueryService; import com.crm.opportunity.domain.dto.OpportunityDTO; import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.domain.entity.OpportunityCustomer; @@ -26,6 +30,8 @@ import com.crm.opportunity.mapper.OpportunitySchemeCardMapper; import com.crm.opportunity.mapper.OpportunityStageHistoryMapper; import com.crm.opportunity.mapper.OpportunityViewLogMapper; import com.crm.opportunity.query.OpportunityViewQuery; +import com.crm.rule.domain.dto.SysRegionDTO; +import com.crm.rule.service.ISysRegionService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -33,6 +39,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -45,12 +53,13 @@ import java.util.stream.Collectors; * {@link #applyViewFilters};{@code @DataScope}({@code Opportunity} 实体注解)经拦截器对 wrapper 自动 * 追加部门天花板。当前步已落:视图 filter + 公共筛选 + 分页 + followed 回显。

* - *

票 10 §二数值计算列(节点停留/最近跟进/方案卡状态/方案预算)已批量 join 填充(无 N+1)。

+ *

票 10 §二数值计算列(节点停留/最近跟进/方案卡状态/方案预算)已批量 join 填充(无 N+1); + * 字典/部门/区划名回显(DictQueryService/SysDeptService/SysRegionService,对称 crm-lead fillDisplayFields) + * 已落。

* - *

TODO(后续步骤):① 字典/部门/区划名回显(DictQueryService/SysDeptService/SysRegionService, - * 对称 crm-lead fillDisplayFields);② 当前/下一节点名(依赖 crm-rule 阶段模板服务);③ RECENT 视图按 - * {@code opportunity_view_log.last_view_time} 倒序取上限(当前 RECENT 先按 create_time 兜底排序,待接 - * view_log join);④ PARTICIPATED 团队白名单可见性叠加详情链路。

+ *

TODO(后续步骤):① 当前/下一节点名(依赖 crm-rule 阶段模板服务);② PARTICIPATED + * 团队白名单可见性叠加详情链路。RECENT 视图已按 {@code opportunity_view_log.last_view_time} 倒序取上限 + * (见 {@link #pageRecent})。

*/ @Component @RequiredArgsConstructor @@ -63,6 +72,9 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { private final OpportunityCustomerMapper customerMapper; private final OpportunitySchemeCardMapper schemeCardMapper; private final OpportunityViewLogMapper viewLogMapper; + private final DictQueryService dictQueryService; + private final ISysDeptService sysDeptService; + private final ISysRegionService sysRegionService; /** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */ private static final int RECENT_LIMIT = 50; @@ -172,8 +184,9 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { } /** - * 出参回显字段填充:followed + 票 10 §二数值计算列(批量,无 N+1)。 - *

TODO 后续:字典/部门/区划名 + 当前/下一节点名(依赖 crm-rule 模板服务)。

+ * 出参回显字段填充:followed + 票 10 §二数值计算列(批量,无 N+1)+ 字典/部门/区划名。 + *

字典名对称 crm-lead {@code fillDictNames}:按 (groupCode, code) 批量查(同页去重); + * 部门名 = {@code owner_dept_id} → SysDept;区划名 = province/city → SysRegion。

*/ private void fillDisplayFields(List dtos, Long currentUserId) { if (CollUtil.isEmpty(dtos)) { @@ -192,6 +205,68 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { fillStageStayDays(dtos, ids); fillLastFollowTime(dtos, ids); fillSchemeCardColumns(dtos, ids); + fillDictNames(dtos); + fillDeptNames(dtos); + fillRegionNames(dtos); + } + + /** + * 字典名称快照回显(对称 crm-lead {@code fillDictNames}):商机来源 / 行业。 + *

groupCode 契约见 PRD §10 字典清单({@code opp_source} / {@code industry_code})。 + * 走 {@link DictQueryService#getItem}(含停用项,扛历史数据回显,Caffeine 缓存), + * 按 (分组, code) 去重后逐个查,同一页内重复值只查一次。DTO 仅暴露 + * {@code oppSourceName}/{@code industryName} 两回显位(oppType/bidForm/localityType 无 Name 位,不填)。

+ */ + private void fillDictNames(List dtos) { + Map sourceNames = dictNames("opp_source", + dtos.stream().map(OpportunityDTO::getOppSource).collect(Collectors.toSet())); + Map industryNames = dictNames("industry_code", + dtos.stream().map(OpportunityDTO::getIndustryCode).collect(Collectors.toSet())); + dtos.forEach(d -> { + d.setOppSourceName(sourceNames.get(d.getOppSource())); + d.setIndustryName(industryNames.get(d.getIndustryCode())); + }); + } + + private Map dictNames(String groupCode, Set codes) { + Map names = new HashMap<>(); + codes.stream().filter(StrUtil::isNotBlank).distinct().forEach(code -> { + DictItem item = dictQueryService.getItem(groupCode, code); + if (item != null) { + names.put(code, item.getName()); + } + }); + return names; + } + + /** 负责人部门名回显(对称 crm-lead {@code fillDeptNames}):owner_dept_id → SysDept.deptName。 */ + private void fillDeptNames(List dtos) { + Set deptIds = dtos.stream().map(OpportunityDTO::getOwnerDeptId) + .filter(java.util.Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + if (deptIds.isEmpty()) { + return; + } + Map deptNameMap = sysDeptService.listByIds(deptIds).stream() + .collect(Collectors.toMap(SysDept::getId, SysDept::getDeptName, (a, b) -> a)); + dtos.forEach(d -> d.setOwnerDeptName(deptNameMap.get(d.getOwnerDeptId()))); + } + + /** 省/市区划名回显(对称 crm-lead 省市名):province_code/city_code → SysRegion.name。 */ + private void fillRegionNames(List dtos) { + Set regionCodes = new HashSet<>(); + dtos.forEach(d -> { + if (StrUtil.isNotBlank(d.getProvinceCode())) regionCodes.add(d.getProvinceCode()); + if (StrUtil.isNotBlank(d.getCityCode())) regionCodes.add(d.getCityCode()); + }); + if (regionCodes.isEmpty()) { + return; + } + Map regionNameMap = sysRegionService.listByCodes(regionCodes).stream() + .collect(Collectors.toMap(SysRegionDTO::getCode, SysRegionDTO::getName, (a, b) -> a)); + dtos.forEach(d -> { + d.setProvinceName(regionNameMap.get(d.getProvinceCode())); + d.setCityName(regionNameMap.get(d.getCityCode())); + }); } /** diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java index de1bcf4..2b45513 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java +++ b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java @@ -64,6 +64,12 @@ class OpportunityViewQueryImplTest { private OpportunitySchemeCardMapper schemeCardMapper; @Mock private OpportunityViewLogMapper viewLogMapper; + @Mock + private com.crm.dict.service.DictQueryService dictQueryService; + @Mock + private com.crm.auth.service.ISysDeptService sysDeptService; + @Mock + private com.crm.rule.service.ISysRegionService sysRegionService; @InjectMocks private OpportunityViewQueryImpl query; @@ -80,6 +86,8 @@ class OpportunityViewQueryImplTest { lenient().when(customerMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(schemeCardMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(viewLogMapper.selectList(any())).thenReturn(Collections.emptyList()); + lenient().when(sysDeptService.listByIds(any())).thenReturn(Collections.emptyList()); + lenient().when(sysRegionService.listByCodes(any())).thenReturn(Collections.emptyList()); } @AfterEach @@ -230,4 +238,53 @@ class OpportunityViewQueryImplTest { assertThat(dto.getSchemeCardStatus()).isEqualTo(2); assertThat(dto.getSchemeBudget()).isEqualByComparingTo("50000.00"); } + + @Test + @DisplayName("回显名:商机来源/行业字典名 + 负责人部门名 + 省/市区划名") + @SuppressWarnings("unchecked") + void displayNames_dictDeptRegion_filled() { + Opportunity opp = new Opportunity(); + opp.setId(7001L); + opp.setOppSource("lead_transfer"); + opp.setIndustryCode("it"); + opp.setOwnerDeptId(300L); + opp.setProvinceCode("440000"); + opp.setCityCode("440300"); + Page page = new Page<>(); + page.setRecords(List.of(opp)); + when(opportunityMapper.selectPage(any(), any())).thenReturn(page); + + // 字典:(opp_source, lead_transfer) / (industry_code, it) + com.crm.dict.domain.entity.DictItem srcItem = new com.crm.dict.domain.entity.DictItem(); + srcItem.setName("线索转入"); + com.crm.dict.domain.entity.DictItem indItem = new com.crm.dict.domain.entity.DictItem(); + indItem.setName("信息技术"); + when(dictQueryService.getItem("opp_source", "lead_transfer")).thenReturn(srcItem); + when(dictQueryService.getItem("industry_code", "it")).thenReturn(indItem); + + // 部门:300 -> 华南大区 + com.crm.auth.domain.entity.SysDept dept = new com.crm.auth.domain.entity.SysDept(); + dept.setId(300L); + dept.setDeptName("华南大区"); + when(sysDeptService.listByIds(any())).thenReturn(List.of(dept)); + + // 区划:440000 广东 / 440300 深圳 + com.crm.rule.domain.dto.SysRegionDTO prov = new com.crm.rule.domain.dto.SysRegionDTO(); + prov.setCode("440000"); + prov.setName("广东省"); + com.crm.rule.domain.dto.SysRegionDTO city = new com.crm.rule.domain.dto.SysRegionDTO(); + city.setCode("440300"); + city.setName("深圳市"); + when(sysRegionService.listByCodes(any())).thenReturn(List.of(prov, city)); + + OpportunityPageParam param = new OpportunityPageParam(); + param.setViewType("MINE"); + OpportunityDTO dto = query.pageOpportunities(param).getContent().get(0); + + assertThat(dto.getOppSourceName()).isEqualTo("线索转入"); + assertThat(dto.getIndustryName()).isEqualTo("信息技术"); + assertThat(dto.getOwnerDeptName()).isEqualTo("华南大区"); + assertThat(dto.getProvinceName()).isEqualTo("广东省"); + assertThat(dto.getCityName()).isEqualTo("深圳市"); + } }