Browse Source

feat(opportunity): 票10 读侧回显字典/部门/区划名 (对称 crm-lead)

- 注入 DictQueryService/ISysDeptService/ISysRegionService
- fillDictNames: opp_source/industry_code 按 (groupCode,code) 批量查名 (PRD §10 契约)
- fillDeptNames: owner_dept_id → SysDept.deptName
- fillRegionNames: province_code/city_code → SysRegion.name
- DTO 仅 oppSourceName/industryName 有 Name 位, oppType/bidForm/localityType 无位不填
- 新增回显名测试, 130 全绿
master
luoweijian 2 weeks ago
parent
commit
65fc1c5ffb
  1. 4
      .scratch/opportunity-module/map.md
  2. 89
      crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
  3. 57
      crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java

4
.scratch/opportunity-module/map.md

@ -30,7 +30,7 @@
| 11 | createCmd customerId | ⛔ 卡 A4 | | 11 | createCmd customerId | ⛔ 卡 A4 |
| 12 | 商机侧新增入口关联线索校验 | ⛔ 实质卡(建商机入口 Service 未建=依赖票 02/11 → A4) | | 12 | 商机侧新增入口关联线索校验 | ⛔ 实质卡(建商机入口 Service 未建=依赖票 02/11 → A4) |
| 13 | 商机-客户关联子表 | ⚠ 部分(实体 `OpportunityCustomer`+约束已落;读写入口/下拉选客户卡 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。 **前沿小结**:商机模块**结构层已全部落地**(主表 + 状态机 + 三族规则 + 六子表 + 视图数据源三表 team/focus/view_log)。剩余:① **票 10 查询层 + 票 14 视图组织/自定义视图 = 可独立开工的读侧大票(不卡 A4)** → 建议每票清上下文单独 `/implement`(底座表已备);② **票 02/11/12/13 读写入口 = 卡 A4 客户模块**(建商机核心逻辑需写关联客户主标记子表 customer_id),挂起等 A4。
@ -87,7 +87,7 @@
<!-- in-scope fog,随前沿推进 graduate --> <!-- in-scope fog,随前沿推进 graduate -->
- **自定义视图实现落地(票 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 占位)。 - **客户关联子表的形态** — 已钉死为 **票 13**(见下 Decisions);仅“下拉选客户”数据源 + 客户联系人是否本子表冗余仍挂 **A4 客户模块(未建)** 边界(票 11 占位)。
- **商机批量操作与统计接口** — 对称线索 G1–G5(批量领取/分配/关注、列表统计卡片)。等主表 + 四视图定稿后再切成票。 - **商机批量操作与统计接口** — 对称线索 G1–G5(批量领取/分配/关注、列表统计卡片)。等主表 + 四视图定稿后再切成票。

89
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 cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.domain.result.PageResult;
import com.crm.base.security.SecurityUtils; import com.crm.base.security.SecurityUtils;
import com.crm.base.utils.PageConverter; 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.dto.OpportunityDTO;
import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.entity.OpportunityCustomer; 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.OpportunityStageHistoryMapper;
import com.crm.opportunity.mapper.OpportunityViewLogMapper; import com.crm.opportunity.mapper.OpportunityViewLogMapper;
import com.crm.opportunity.query.OpportunityViewQuery; import com.crm.opportunity.query.OpportunityViewQuery;
import com.crm.rule.domain.dto.SysRegionDTO;
import com.crm.rule.service.ISysRegionService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -33,6 +39,8 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -45,12 +53,13 @@ import java.util.stream.Collectors;
* {@link #applyViewFilters}{@code @DataScope}{@code Opportunity} 实体注解经拦截器对 wrapper 自动 * {@link #applyViewFilters}{@code @DataScope}{@code Opportunity} 实体注解经拦截器对 wrapper 自动
* 追加部门天花板当前步已落视图 filter + 公共筛选 + 分页 + followed 回显</p> * 追加部门天花板当前步已落视图 filter + 公共筛选 + 分页 + followed 回显</p>
* *
* <p> 10 §二数值计算列节点停留/最近跟进/方案卡状态/方案预算已批量 join 填充 N+1</p> * <p> 10 §二数值计算列节点停留/最近跟进/方案卡状态/方案预算已批量 join 填充 N+1
* 字典/部门/区划名回显DictQueryService/SysDeptService/SysRegionService对称 crm-lead fillDisplayFields
* 已落</p>
* *
* <p><b>TODO后续步骤</b> 字典/部门/区划名回显DictQueryService/SysDeptService/SysRegionService, * <p><b>TODO后续步骤</b> 当前/下一节点名依赖 crm-rule 阶段模板服务 PARTICIPATED
* 对称 crm-lead fillDisplayFields 当前/下一节点名依赖 crm-rule 阶段模板服务 RECENT 视图按 * 团队白名单可见性叠加详情链路RECENT 视图已按 {@code opportunity_view_log.last_view_time} 倒序取上限
* {@code opportunity_view_log.last_view_time} 倒序取上限当前 RECENT 先按 create_time 兜底排序待接 * {@link #pageRecent}</p>
* view_log join PARTICIPATED 团队白名单可见性叠加详情链路</p>
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@ -63,6 +72,9 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final OpportunityCustomerMapper customerMapper; private final OpportunityCustomerMapper customerMapper;
private final OpportunitySchemeCardMapper schemeCardMapper; private final OpportunitySchemeCardMapper schemeCardMapper;
private final OpportunityViewLogMapper viewLogMapper; private final OpportunityViewLogMapper viewLogMapper;
private final DictQueryService dictQueryService;
private final ISysDeptService sysDeptService;
private final ISysRegionService sysRegionService;
/** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */ /** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */
private static final int RECENT_LIMIT = 50; private static final int RECENT_LIMIT = 50;
@ -172,8 +184,9 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
} }
/** /**
* 出参回显字段填充followed + 10 §二数值计算列批量 N+1 * 出参回显字段填充followed + 10 §二数值计算列批量 N+1+ 字典/部门/区划名
* <p>TODO 后续字典/部门/区划名 + 当前/下一节点名依赖 crm-rule 模板服务</p> * <p>字典名对称 crm-lead {@code fillDictNames} (groupCode, code) 批量查同页去重
* 部门名 = {@code owner_dept_id} SysDept区划名 = province/city SysRegion</p>
*/ */
private void fillDisplayFields(List<OpportunityDTO> dtos, Long currentUserId) { private void fillDisplayFields(List<OpportunityDTO> dtos, Long currentUserId) {
if (CollUtil.isEmpty(dtos)) { if (CollUtil.isEmpty(dtos)) {
@ -192,6 +205,68 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
fillStageStayDays(dtos, ids); fillStageStayDays(dtos, ids);
fillLastFollowTime(dtos, ids); fillLastFollowTime(dtos, ids);
fillSchemeCardColumns(dtos, ids); fillSchemeCardColumns(dtos, ids);
fillDictNames(dtos);
fillDeptNames(dtos);
fillRegionNames(dtos);
}
/**
* 字典名称快照回显对称 crm-lead {@code fillDictNames}商机来源 / 行业
* <p>groupCode 契约见 PRD §10 字典清单{@code opp_source} / {@code industry_code}
* {@link DictQueryService#getItem}含停用项扛历史数据回显Caffeine 缓存
* (分组, code) 去重后逐个查同一页内重复值只查一次DTO 仅暴露
* {@code oppSourceName}/{@code industryName} 两回显位oppType/bidForm/localityType Name 不填</p>
*/
private void fillDictNames(List<OpportunityDTO> dtos) {
Map<String, String> sourceNames = dictNames("opp_source",
dtos.stream().map(OpportunityDTO::getOppSource).collect(Collectors.toSet()));
Map<String, String> 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<String, String> dictNames(String groupCode, Set<String> codes) {
Map<String, String> 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<OpportunityDTO> dtos) {
Set<Long> deptIds = dtos.stream().map(OpportunityDTO::getOwnerDeptId)
.filter(java.util.Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
if (deptIds.isEmpty()) {
return;
}
Map<Long, String> 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<OpportunityDTO> dtos) {
Set<String> 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<String, String> 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()));
});
} }
/** /**

57
crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java

@ -64,6 +64,12 @@ class OpportunityViewQueryImplTest {
private OpportunitySchemeCardMapper schemeCardMapper; private OpportunitySchemeCardMapper schemeCardMapper;
@Mock @Mock
private OpportunityViewLogMapper viewLogMapper; 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 @InjectMocks
private OpportunityViewQueryImpl query; private OpportunityViewQueryImpl query;
@ -80,6 +86,8 @@ class OpportunityViewQueryImplTest {
lenient().when(customerMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(customerMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(schemeCardMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(schemeCardMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(viewLogMapper.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 @AfterEach
@ -230,4 +238,53 @@ class OpportunityViewQueryImplTest {
assertThat(dto.getSchemeCardStatus()).isEqualTo(2); assertThat(dto.getSchemeCardStatus()).isEqualTo(2);
assertThat(dto.getSchemeBudget()).isEqualByComparingTo("50000.00"); 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<Opportunity> 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("深圳市");
}
} }

Loading…
Cancel
Save