You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
681 lines
35 KiB
681 lines
35 KiB
### PART 1: unstaged diff (crm-opportunity + crm-rule) === ticket-12 delta vs staged baseline
|
|
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java
|
|
index fa6c00e..32e1687 100644
|
|
--- a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java
|
|
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java
|
|
@@ -9,12 +9,15 @@ import com.crm.opportunity.domain.dto.OpportunityCustomerAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityCustomerCandidateDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamUpdateDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanAddDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanUpdateDTO;
|
|
import com.crm.opportunity.domain.entity.OpportunityAttachment;
|
|
import com.crm.opportunity.domain.entity.OpportunityCustomer;
|
|
import com.crm.opportunity.domain.entity.OpportunityFollow;
|
|
import com.crm.opportunity.domain.entity.OpportunityOplog;
|
|
import com.crm.opportunity.domain.entity.OpportunitySiteSurvey;
|
|
import com.crm.opportunity.domain.entity.OpportunityTeam;
|
|
+import com.crm.opportunity.domain.entity.OpportunityWorkPlan;
|
|
import com.crm.opportunity.domain.param.OpportunityCustomerSearchParam;
|
|
import com.crm.opportunity.service.IOpportunitySubService;
|
|
import io.swagger.v3.oas.annotations.Operation;
|
|
@@ -36,7 +39,8 @@ import java.util.Set;
|
|
/**
|
|
* 商机详情 Tab 子域接口(票 03 整改,薄适配层;票 04 补客户/团队写端点)。
|
|
*
|
|
- * <p>六个子域:客户/跟进/勘察/附件/团队/日志。路由风格同项目其他 Controller:
|
|
+ * <p>子域:客户/跟进/勘察/附件/团队/日志/工作计划(票 12 补 workplan,精简 A1X)。
|
|
+ * 路由风格同项目其他 Controller:
|
|
* GET 查询,POST 写操作,id 走 @RequestParam,分页走 POST /page。
|
|
* 写入 ≥3 参按 ADR-0017 用 DTO 隐式表单绑定,≤2 参保持 @RequestParam。</p>
|
|
*/
|
|
@@ -251,4 +255,35 @@ public class OpportunitySubController {
|
|
@RequestParam(value = "pageSize", defaultValue = "10") long pageSize) {
|
|
return Result.success(subService.pageOplogs(oppId, pageNum, pageSize));
|
|
}
|
|
+
|
|
+ // ==================== 工作计划 Tab(票 12,精简 A1X) ====================
|
|
+
|
|
+ @Operation(summary = "工作计划列表")
|
|
+ @GetMapping("/workplan/list")
|
|
+ public Result<List<OpportunityWorkPlan>> listWorkPlans(@RequestParam("oppId") Long oppId) {
|
|
+ return Result.success(subService.listWorkPlans(oppId));
|
|
+ }
|
|
+
|
|
+ @Operation(summary = "新增工作计划")
|
|
+ @PostMapping("/workplan/add")
|
|
+ public Result<Long> addWorkPlan(OpportunityWorkPlanAddDTO dto) {
|
|
+ Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId());
|
|
+ return Result.success(subService.addWorkPlan(dto, operatorId));
|
|
+ }
|
|
+
|
|
+ @Operation(summary = "编辑工作计划(登记完成:planStatus=1 服务端回填 finishTime,0 清空)")
|
|
+ @PostMapping("/workplan/update")
|
|
+ public Result<Void> updateWorkPlan(OpportunityWorkPlanUpdateDTO dto) {
|
|
+ Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId());
|
|
+ subService.updateWorkPlan(dto, operatorId);
|
|
+ return Result.success();
|
|
+ }
|
|
+
|
|
+ @Operation(summary = "删除工作计划")
|
|
+ @PostMapping("/workplan/delete")
|
|
+ public Result<Void> deleteWorkPlan(@RequestParam("id") Long id) {
|
|
+ Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId());
|
|
+ subService.deleteWorkPlan(id, operatorId);
|
|
+ return Result.success();
|
|
+ }
|
|
}
|
|
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java
|
|
index 1437cb3..74128fd 100644
|
|
--- a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java
|
|
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java
|
|
@@ -5,12 +5,15 @@ import com.crm.opportunity.domain.dto.OpportunityCustomerAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityCustomerCandidateDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamUpdateDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanAddDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanUpdateDTO;
|
|
import com.crm.opportunity.domain.entity.OpportunityAttachment;
|
|
import com.crm.opportunity.domain.entity.OpportunityCustomer;
|
|
import com.crm.opportunity.domain.entity.OpportunityFollow;
|
|
import com.crm.opportunity.domain.entity.OpportunityOplog;
|
|
import com.crm.opportunity.domain.entity.OpportunitySiteSurvey;
|
|
import com.crm.opportunity.domain.entity.OpportunityTeam;
|
|
+import com.crm.opportunity.domain.entity.OpportunityWorkPlan;
|
|
import com.crm.opportunity.domain.param.OpportunityCustomerSearchParam;
|
|
|
|
import java.util.List;
|
|
@@ -74,4 +77,18 @@ public interface IOpportunitySubService {
|
|
|
|
/** 操作日志分页 */
|
|
PageResult<OpportunityOplog> pageOplogs(Long oppId, long pageNum, long pageSize);
|
|
+
|
|
+ // ==================== 工作计划 Tab(票 12,精简 A1X) ====================
|
|
+
|
|
+ /** 工作计划列表(存活行,createTime 升序;逾期实时计算不落库,前端按 deadline+planStatus 实时判) */
|
|
+ List<OpportunityWorkPlan> listWorkPlans(Long oppId);
|
|
+
|
|
+ /** 新增工作计划(票 12:planContent 必填 ≤1000;暂缓中禁写;写 ROW_ADD 日志) */
|
|
+ Long addWorkPlan(OpportunityWorkPlanAddDTO dto, Long operatorId);
|
|
+
|
|
+ /** 编辑工作计划(票 12:部分更新显式 set;planStatus 0→1 回填 finishTime=now / 1→0 清空;值域封闭 0/1) */
|
|
+ void updateWorkPlan(OpportunityWorkPlanUpdateDTO dto, Long operatorId);
|
|
+
|
|
+ /** 删除工作计划(票 12:软删 delete_key=id 复用键;写 ROW_DELETE 日志) */
|
|
+ void deleteWorkPlan(Long id, Long operatorId);
|
|
}
|
|
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java
|
|
index 90b294c..f8bc60a 100644
|
|
--- a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java
|
|
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java
|
|
@@ -14,6 +14,8 @@ import com.crm.opportunity.domain.dto.OpportunityCustomerAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityCustomerCandidateDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamUpdateDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanAddDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanUpdateDTO;
|
|
import com.crm.opportunity.domain.entity.Opportunity;
|
|
import com.crm.opportunity.domain.entity.OpportunityAttachment;
|
|
import com.crm.opportunity.domain.entity.OpportunityCustomer;
|
|
@@ -21,6 +23,7 @@ import com.crm.opportunity.domain.entity.OpportunityFollow;
|
|
import com.crm.opportunity.domain.entity.OpportunityOplog;
|
|
import com.crm.opportunity.domain.entity.OpportunitySiteSurvey;
|
|
import com.crm.opportunity.domain.entity.OpportunityTeam;
|
|
+import com.crm.opportunity.domain.entity.OpportunityWorkPlan;
|
|
import com.crm.opportunity.domain.enums.OplogKind;
|
|
import com.crm.opportunity.domain.enums.OplogLogType;
|
|
import com.crm.opportunity.domain.enums.TeamMemberPermission;
|
|
@@ -32,6 +35,7 @@ import com.crm.opportunity.mapper.OpportunityMapper;
|
|
import com.crm.opportunity.mapper.OpportunityOplogMapper;
|
|
import com.crm.opportunity.mapper.OpportunitySiteSurveyMapper;
|
|
import com.crm.opportunity.mapper.OpportunityTeamMapper;
|
|
+import com.crm.opportunity.mapper.OpportunityWorkPlanMapper;
|
|
import com.crm.opportunity.service.IOpportunitySubService;
|
|
import com.crm.opportunity.state.OpportunityActionGuard;
|
|
import lombok.RequiredArgsConstructor;
|
|
@@ -54,11 +58,15 @@ public class OpportunitySubServiceImpl implements IOpportunitySubService {
|
|
/** 跟进附件每条记录最多关联附件数(规格约定:10 个) */
|
|
private static final int FOLLOW_ATTACHMENT_MAX = 10;
|
|
|
|
+ /** 工作计划内容长度上限(表结构 varchar(1000) 同源) */
|
|
+ private static final int PLAN_CONTENT_MAX = 1000;
|
|
+
|
|
private final OpportunityCustomerMapper customerMapper;
|
|
private final OpportunityFollowMapper followMapper;
|
|
private final OpportunitySiteSurveyMapper siteSurveyMapper;
|
|
private final OpportunityAttachmentMapper attachmentMapper;
|
|
private final OpportunityTeamMapper teamMapper;
|
|
+ private final OpportunityWorkPlanMapper workPlanMapper;
|
|
private final OpportunityOplogMapper oplogMapper;
|
|
private final OpportunityMapper oppMapper;
|
|
private final IAuthUserService authUserService;
|
|
@@ -456,6 +464,94 @@ public class OpportunitySubServiceImpl implements IOpportunitySubService {
|
|
row.getUserNameSnapshot(), "移除团队成员:" + row.getUserNameSnapshot());
|
|
}
|
|
|
|
+ // ==================== 工作计划(票 12,精简 A1X) ====================
|
|
+
|
|
+ @Override
|
|
+ public List<OpportunityWorkPlan> listWorkPlans(Long oppId) {
|
|
+ return workPlanMapper.selectList(new LambdaQueryWrapper<OpportunityWorkPlan>()
|
|
+ .eq(OpportunityWorkPlan::getOppId, oppId)
|
|
+ .eq(OpportunityWorkPlan::getDeleteKey, 0L)
|
|
+ .orderByAsc(OpportunityWorkPlan::getCreateTime));
|
|
+ }
|
|
+
|
|
+ @Override
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
+ public Long addWorkPlan(OpportunityWorkPlanAddDTO dto, Long operatorId) {
|
|
+ if (dto.getOppId() == null) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "oppId 必填");
|
|
+ }
|
|
+ if (!StringUtils.hasText(dto.getPlanContent())) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "计划内容必填");
|
|
+ }
|
|
+ if (dto.getPlanContent().length() > PLAN_CONTENT_MAX) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID,
|
|
+ "计划内容不能超过 " + PLAN_CONTENT_MAX + " 字");
|
|
+ }
|
|
+ Opportunity opp = requireOpp(dto.getOppId());
|
|
+ // D-07:暂缓中子表写同禁
|
|
+ actionGuard.ensureNotPaused(opp);
|
|
+ OpportunityWorkPlan plan = dto.toEntity();
|
|
+ plan.setDeleteKey(0L);
|
|
+ workPlanMapper.insert(plan);
|
|
+ writeRowLog(dto.getOppId(), operatorId, OplogKind.ROW_ADD, "opportunity_work_plan",
|
|
+ plan.getPlanContent(), "新增工作计划");
|
|
+ return plan.getId();
|
|
+ }
|
|
+
|
|
+ @Override
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
+ public void updateWorkPlan(OpportunityWorkPlanUpdateDTO dto, Long operatorId) {
|
|
+ if (dto.getId() == null) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "id 必填");
|
|
+ }
|
|
+ if (dto.getPlanStatus() != null && dto.getPlanStatus() != 0 && dto.getPlanStatus() != 1) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "planStatus 只允许 0/1");
|
|
+ }
|
|
+ if (dto.getPlanContent() != null
|
|
+ && (!StringUtils.hasText(dto.getPlanContent()) || dto.getPlanContent().length() > PLAN_CONTENT_MAX)) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID,
|
|
+ "计划内容必填且不能超过 " + PLAN_CONTENT_MAX + " 字");
|
|
+ }
|
|
+ OpportunityWorkPlan plan = workPlanMapper.selectById(dto.getId());
|
|
+ if (plan == null || plan.getDeleteKey() != 0L) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_NOT_EXIST, "工作计划不存在");
|
|
+ }
|
|
+ Opportunity opp = requireOpp(plan.getOppId());
|
|
+ actionGuard.ensureNotPaused(opp);
|
|
+ // 部分更新显式 set(票 06 整实体 update 清字段 bug 教训);登记完成回填:0→1 写 finishTime=now / 1→0 清空
|
|
+ LambdaUpdateWrapper<OpportunityWorkPlan> wrapper = new LambdaUpdateWrapper<OpportunityWorkPlan>()
|
|
+ .eq(OpportunityWorkPlan::getId, dto.getId());
|
|
+ if (StringUtils.hasText(dto.getPlanContent())) {
|
|
+ wrapper.set(OpportunityWorkPlan::getPlanContent, dto.getPlanContent());
|
|
+ }
|
|
+ if (dto.getDeadline() != null) {
|
|
+ wrapper.set(OpportunityWorkPlan::getDeadline, dto.getDeadline());
|
|
+ }
|
|
+ if (dto.getPlanStatus() != null) {
|
|
+ wrapper.set(OpportunityWorkPlan::getPlanStatus, dto.getPlanStatus());
|
|
+ wrapper.set(OpportunityWorkPlan::getFinishTime,
|
|
+ dto.getPlanStatus() == 1 ? LocalDateTime.now() : null);
|
|
+ }
|
|
+ workPlanMapper.update(null, wrapper);
|
|
+ }
|
|
+
|
|
+ @Override
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
+ public void deleteWorkPlan(Long id, Long operatorId) {
|
|
+ OpportunityWorkPlan plan = workPlanMapper.selectById(id);
|
|
+ if (plan == null || plan.getDeleteKey() != 0L) {
|
|
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_NOT_EXIST, "工作计划不存在");
|
|
+ }
|
|
+ Opportunity opp = requireOpp(plan.getOppId());
|
|
+ actionGuard.ensureNotPaused(opp);
|
|
+ workPlanMapper.update(null, new LambdaUpdateWrapper<OpportunityWorkPlan>()
|
|
+ .eq(OpportunityWorkPlan::getId, id)
|
|
+ .set(OpportunityWorkPlan::getDeleteKey, id)
|
|
+ .set(OpportunityWorkPlan::getDeleted, 1));
|
|
+ writeRowLog(plan.getOppId(), operatorId, OplogKind.ROW_DELETE, "opportunity_work_plan",
|
|
+ plan.getPlanContent(), "删除工作计划");
|
|
+ }
|
|
+
|
|
/** 商机存在性守卫(@TableLogic 自动过滤已删行) */
|
|
private Opportunity requireOpp(Long oppId) {
|
|
Opportunity opp = oppMapper.selectById(oppId);
|
|
diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunitySubServiceImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunitySubServiceImplTest.java
|
|
index b235836..00c9740 100644
|
|
--- a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunitySubServiceImplTest.java
|
|
+++ b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunitySubServiceImplTest.java
|
|
@@ -1,6 +1,7 @@
|
|
package com.crm.opportunity.service.impl;
|
|
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
import com.crm.auth.domain.entity.AuthUser;
|
|
@@ -12,10 +13,13 @@ import com.crm.opportunity.domain.dto.OpportunityCustomerAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityCustomerCandidateDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamAddDTO;
|
|
import com.crm.opportunity.domain.dto.OpportunityTeamUpdateDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanAddDTO;
|
|
+import com.crm.opportunity.domain.dto.OpportunityWorkPlanUpdateDTO;
|
|
import com.crm.opportunity.domain.entity.Opportunity;
|
|
import com.crm.opportunity.domain.entity.OpportunityCustomer;
|
|
import com.crm.opportunity.domain.entity.OpportunityOplog;
|
|
import com.crm.opportunity.domain.entity.OpportunityTeam;
|
|
+import com.crm.opportunity.domain.entity.OpportunityWorkPlan;
|
|
import com.crm.opportunity.domain.enums.OplogKind;
|
|
import com.crm.opportunity.domain.param.OpportunityCustomerSearchParam;
|
|
import com.crm.opportunity.mapper.OpportunityAttachmentMapper;
|
|
@@ -25,6 +29,7 @@ import com.crm.opportunity.mapper.OpportunityMapper;
|
|
import com.crm.opportunity.mapper.OpportunityOplogMapper;
|
|
import com.crm.opportunity.mapper.OpportunitySiteSurveyMapper;
|
|
import com.crm.opportunity.mapper.OpportunityTeamMapper;
|
|
+import com.crm.opportunity.mapper.OpportunityWorkPlanMapper;
|
|
import com.crm.opportunity.state.OpportunityActionGuard;
|
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
|
|
@@ -38,6 +43,7 @@ import org.mockito.Mock;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
import java.util.List;
|
|
+import java.util.Objects;
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
@@ -46,6 +52,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.ArgumentMatchers.isNull;
|
|
import static org.mockito.Mockito.doAnswer;
|
|
+import static org.mockito.Mockito.doThrow;
|
|
import static org.mockito.Mockito.never;
|
|
import static org.mockito.Mockito.times;
|
|
import static org.mockito.Mockito.verify;
|
|
@@ -59,7 +66,7 @@ import static org.mockito.Mockito.when;
|
|
* 负责人行锁定(66013 不可移除/不可换人/角色不可改)、普通成员换人直接替换 + 姓名快照服务端取。</p>
|
|
*/
|
|
@ExtendWith(MockitoExtension.class)
|
|
-@DisplayName("票04 商机子域写端点(客户/团队)")
|
|
+@DisplayName("票04 商机子域写端点(客户/团队)+ 票12 工作计划 Tab")
|
|
class OpportunitySubServiceImplTest {
|
|
|
|
private static final Long OPP_ID = 7001L;
|
|
@@ -72,6 +79,7 @@ class OpportunitySubServiceImplTest {
|
|
@Mock private OpportunitySiteSurveyMapper siteSurveyMapper;
|
|
@Mock private OpportunityAttachmentMapper attachmentMapper;
|
|
@Mock private OpportunityTeamMapper teamMapper;
|
|
+ @Mock private OpportunityWorkPlanMapper workPlanMapper;
|
|
@Mock private OpportunityOplogMapper oplogMapper;
|
|
@Mock private OpportunityMapper oppMapper;
|
|
@Mock private IAuthUserService authUserService;
|
|
@@ -88,6 +96,7 @@ class OpportunitySubServiceImplTest {
|
|
TableInfoHelper.initTableInfo(assistant, Opportunity.class);
|
|
TableInfoHelper.initTableInfo(assistant, OpportunityCustomer.class);
|
|
TableInfoHelper.initTableInfo(assistant, OpportunityTeam.class);
|
|
+ TableInfoHelper.initTableInfo(assistant, OpportunityWorkPlan.class);
|
|
}
|
|
|
|
/** 业务异常断言:code 命中错误码常量 + 消息含关键片段。 */
|
|
@@ -431,4 +440,199 @@ class OpportunitySubServiceImplTest {
|
|
assertThat(logCap.getValue().getEntityName()).isEqualTo("opportunity_team");
|
|
assertThat(logCap.getValue().getOpDesc()).contains("移除团队成员");
|
|
}
|
|
+
|
|
+ // ==================== 工作计划(票 12):addWorkPlan ====================
|
|
+
|
|
+ private OpportunityWorkPlan planRow(Long id, Integer planStatus) {
|
|
+ OpportunityWorkPlan row = new OpportunityWorkPlan();
|
|
+ row.setId(id);
|
|
+ row.setOppId(OPP_ID);
|
|
+ row.setPlanContent("推进方案卡评审");
|
|
+ row.setPlanStatus(planStatus);
|
|
+ row.setDeleteKey(0L);
|
|
+ return row;
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 新增主路径:delete_key=0 落库 + ROW_ADD 日志(entityName=opportunity_work_plan)")
|
|
+ void addWorkPlan_normal() {
|
|
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
|
|
+ AtomicLong seq = new AtomicLong(9100L);
|
|
+ doAnswer(inv -> {
|
|
+ inv.getArgument(0, OpportunityWorkPlan.class).setId(seq.getAndIncrement());
|
|
+ return 1;
|
|
+ }).when(workPlanMapper).insert(any(OpportunityWorkPlan.class));
|
|
+
|
|
+ OpportunityWorkPlanAddDTO dto = new OpportunityWorkPlanAddDTO();
|
|
+ dto.setOppId(OPP_ID);
|
|
+ dto.setPlanContent("下周完成方案卡评审");
|
|
+ Long id = service.addWorkPlan(dto, OPERATOR);
|
|
+
|
|
+ assertThat(id).isEqualTo(9100L);
|
|
+ ArgumentCaptor<OpportunityWorkPlan> cap = ArgumentCaptor.forClass(OpportunityWorkPlan.class);
|
|
+ verify(workPlanMapper).insert(cap.capture());
|
|
+ assertThat(cap.getValue().getDeleteKey()).isEqualTo(0L);
|
|
+ assertThat(cap.getValue().getPlanContent()).isEqualTo("下周完成方案卡评审");
|
|
+ ArgumentCaptor<OpportunityOplog> logCap = ArgumentCaptor.forClass(OpportunityOplog.class);
|
|
+ verify(oplogMapper).insert(logCap.capture());
|
|
+ assertThat(logCap.getValue().getOpKind()).isEqualTo(OplogKind.ROW_ADD.getValue());
|
|
+ assertThat(logCap.getValue().getEntityName()).isEqualTo("opportunity_work_plan");
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 计划内容必填(空白)→ 66001")
|
|
+ void addWorkPlan_blankContent_rejected() {
|
|
+ OpportunityWorkPlanAddDTO dto = new OpportunityWorkPlanAddDTO();
|
|
+ dto.setOppId(OPP_ID);
|
|
+ dto.setPlanContent(" ");
|
|
+
|
|
+ assertBiz(() -> service.addWorkPlan(dto, OPERATOR),
|
|
+ OpportunityConstants.CODE_OPP_INVALID, "计划内容必填");
|
|
+ verify(workPlanMapper, never()).insert(any(OpportunityWorkPlan.class));
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 计划内容超 1000 字 → 66001")
|
|
+ void addWorkPlan_tooLongContent_rejected() {
|
|
+ OpportunityWorkPlanAddDTO dto = new OpportunityWorkPlanAddDTO();
|
|
+ dto.setOppId(OPP_ID);
|
|
+ dto.setPlanContent("长".repeat(1001));
|
|
+
|
|
+ assertBiz(() -> service.addWorkPlan(dto, OPERATOR),
|
|
+ OpportunityConstants.CODE_OPP_INVALID, "1000");
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 暂缓中写禁(D-07 同构守卫)→ 66003,不落库")
|
|
+ void addWorkPlan_paused_rejected() {
|
|
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
|
|
+ doThrow(new BusinessErrorException(OpportunityConstants.CODE_STATUS_NOT_ALLOWED, "商机暂缓中,禁止该操作"))
|
|
+ .when(actionGuard).ensureNotPaused(any());
|
|
+
|
|
+ OpportunityWorkPlanAddDTO dto = new OpportunityWorkPlanAddDTO();
|
|
+ dto.setOppId(OPP_ID);
|
|
+ dto.setPlanContent("暂缓期新增");
|
|
+
|
|
+ assertBiz(() -> service.addWorkPlan(dto, OPERATOR),
|
|
+ OpportunityConstants.CODE_STATUS_NOT_ALLOWED, "暂缓");
|
|
+ verify(workPlanMapper, never()).insert(any(OpportunityWorkPlan.class));
|
|
+ }
|
|
+
|
|
+ // ==================== 工作计划(票 12):updateWorkPlan ====================
|
|
+
|
|
+ @SuppressWarnings("unchecked")
|
|
+ private LambdaUpdateWrapper<OpportunityWorkPlan> captureUpdate() {
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<OpportunityWorkPlan>> cap =
|
|
+ ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
+ verify(workPlanMapper).update(isNull(), cap.capture());
|
|
+ return cap.getValue();
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 登记完成:planStatus 0→1 wrapper set 含 plan_status/finish_time 且值为 1")
|
|
+ void updateWorkPlan_registerComplete_setsFinishTime() {
|
|
+ when(workPlanMapper.selectById(31L)).thenReturn(planRow(31L, 0));
|
|
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
|
|
+
|
|
+ OpportunityWorkPlanUpdateDTO dto = new OpportunityWorkPlanUpdateDTO();
|
|
+ dto.setId(31L);
|
|
+ dto.setPlanStatus(1);
|
|
+ service.updateWorkPlan(dto, OPERATOR);
|
|
+
|
|
+ LambdaUpdateWrapper<OpportunityWorkPlan> wrapper = captureUpdate();
|
|
+ assertThat(wrapper.getSqlSet()).contains("plan_status").contains("finish_time");
|
|
+ assertThat(wrapper.getParamNameValuePairs().values()).contains(1);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 取消完成:planStatus 1→0 清 finish_time(set null)")
|
|
+ void updateWorkPlan_cancelComplete_clearsFinishTime() {
|
|
+ when(workPlanMapper.selectById(31L)).thenReturn(planRow(31L, 1));
|
|
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
|
|
+
|
|
+ OpportunityWorkPlanUpdateDTO dto = new OpportunityWorkPlanUpdateDTO();
|
|
+ dto.setId(31L);
|
|
+ dto.setPlanStatus(0);
|
|
+ service.updateWorkPlan(dto, OPERATOR);
|
|
+
|
|
+ LambdaUpdateWrapper<OpportunityWorkPlan> wrapper = captureUpdate();
|
|
+ assertThat(wrapper.getSqlSet()).contains("finish_time");
|
|
+ assertThat(wrapper.getParamNameValuePairs().values()).contains(0);
|
|
+ assertThat(wrapper.getParamNameValuePairs().values()).anyMatch(Objects::isNull);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 部分更新:仅传内容不触碰状态列(未传字段不动)")
|
|
+ void updateWorkPlan_partialUpdate_onlySetsProvided() {
|
|
+ when(workPlanMapper.selectById(31L)).thenReturn(planRow(31L, 0));
|
|
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
|
|
+
|
|
+ OpportunityWorkPlanUpdateDTO dto = new OpportunityWorkPlanUpdateDTO();
|
|
+ dto.setId(31L);
|
|
+ dto.setPlanContent("改期后的内容");
|
|
+ service.updateWorkPlan(dto, OPERATOR);
|
|
+
|
|
+ assertThat(captureUpdate().getSqlSet()).contains("plan_content").doesNotContain("plan_status");
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 planStatus 值域封闭:非 0/1 → 66001")
|
|
+ void updateWorkPlan_badPlanStatus_rejected() {
|
|
+ OpportunityWorkPlanUpdateDTO dto = new OpportunityWorkPlanUpdateDTO();
|
|
+ dto.setId(31L);
|
|
+ dto.setPlanStatus(2);
|
|
+
|
|
+ assertBiz(() -> service.updateWorkPlan(dto, OPERATOR),
|
|
+ OpportunityConstants.CODE_OPP_INVALID, "0/1");
|
|
+ verify(workPlanMapper, never()).update(any(), any());
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 编辑目标不存在 → 66002")
|
|
+ void updateWorkPlan_missing_rejected() {
|
|
+ when(workPlanMapper.selectById(31L)).thenReturn(null);
|
|
+
|
|
+ OpportunityWorkPlanUpdateDTO dto = new OpportunityWorkPlanUpdateDTO();
|
|
+ dto.setId(31L);
|
|
+ dto.setPlanContent("x");
|
|
+ assertBiz(() -> service.updateWorkPlan(dto, OPERATOR),
|
|
+ OpportunityConstants.CODE_OPP_NOT_EXIST, "工作计划不存在");
|
|
+ }
|
|
+
|
|
+ // ==================== 工作计划(票 12):deleteWorkPlan / listWorkPlans ====================
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 删除主路径:软删 delete_key=主键 + ROW_DELETE 日志")
|
|
+ void deleteWorkPlan_normal_softDelete() {
|
|
+ when(workPlanMapper.selectById(31L)).thenReturn(planRow(31L, 0));
|
|
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
|
|
+
|
|
+ service.deleteWorkPlan(31L, OPERATOR);
|
|
+
|
|
+ verify(workPlanMapper).update(isNull(), any());
|
|
+ ArgumentCaptor<OpportunityOplog> logCap = ArgumentCaptor.forClass(OpportunityOplog.class);
|
|
+ verify(oplogMapper).insert(logCap.capture());
|
|
+ assertThat(logCap.getValue().getOpKind()).isEqualTo(OplogKind.ROW_DELETE.getValue());
|
|
+ assertThat(logCap.getValue().getEntityName()).isEqualTo("opportunity_work_plan");
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 删除目标不存在 → 66002")
|
|
+ void deleteWorkPlan_missing_rejected() {
|
|
+ when(workPlanMapper.selectById(31L)).thenReturn(null);
|
|
+
|
|
+ assertBiz(() -> service.deleteWorkPlan(31L, OPERATOR),
|
|
+ OpportunityConstants.CODE_OPP_NOT_EXIST, "工作计划不存在");
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("票12 列表:走 mapper 存活行查询并原样返回")
|
|
+ void listWorkPlans_normal() {
|
|
+ when(workPlanMapper.selectList(any())).thenReturn(List.of(planRow(31L, 0)));
|
|
+
|
|
+ List<OpportunityWorkPlan> rows = service.listWorkPlans(OPP_ID);
|
|
+
|
|
+ assertThat(rows).hasSize(1);
|
|
+ assertThat(rows.get(0).getId()).isEqualTo(31L);
|
|
+ }
|
|
}
|
|
|
|
|
|
### PART 2: unstaged diff (seed-opportunity.py workplan block)
|
|
diff --git a/.scratch/opportunity-e2e/seed-opportunity.py b/.scratch/opportunity-e2e/seed-opportunity.py
|
|
index 7f2ef4d..3560939 100644
|
|
--- a/.scratch/opportunity-e2e/seed-opportunity.py
|
|
+++ b/.scratch/opportunity-e2e/seed-opportunity.py
|
|
@@ -46,7 +46,7 @@ DEPT_CHAMPION = '744841308677341184' # 冠军团队(B 主部门,票 11 禁
|
|
defects = [] # 非预期响应收集(票面要求:任何一步非预期都记入缺陷草稿)
|
|
manifest = {'opportunities': [], 'pool_rules': [], 'scheme_templates': [],
|
|
'follows': [], 'surveys': [], 'scheme_cards': [], 'saved_views': [],
|
|
- 'customers': [], 'team_members': [],
|
|
+ 'customers': [], 'team_members': [], 'workplans': [],
|
|
'observations': [], 'checks': {}, 'skipped': []}
|
|
|
|
_seed_customers = {} # opp_id -> (customer_id, customer_name) DB 补的关联客户
|
|
@@ -656,6 +656,45 @@ def seed_matrix(admin, A, B, C, cur):
|
|
api_add_team(B, b1, [UID['A']], 'project_role_04', '报价与投标支持', 2, 'b1+A')
|
|
api_add_team(B, b1, [UID['C']], 'project_role_06', '跨部门协同', 1, 'b1+C')
|
|
|
|
+ # --- 工作计划(票 12 API 化):a2 三形态样例(逾期/临期/已完成),逾期实时计算语义 ---
|
|
+ # 幂等:先 list 按 planContent 精确匹配复用(--skip-db-clean 重跑不重复 add);
|
|
+ # 复用时校准 deadline(相对当前时间刷新,逾期/临期语义保鲜)与 planStatus(登记完成走 update 回填 finishTime)
|
|
+ if a2:
|
|
+ now = dt.datetime.now()
|
|
+ wp_defs = [
|
|
+ ('e2e-wp-逾期样例(整理现场勘察纪要并回传)', now - dt.timedelta(days=2), 0),
|
|
+ ('e2e-wp-临期样例(本周内完成方案卡评审)', now + dt.timedelta(days=3), 0),
|
|
+ ('e2e-wp-已完成样例(提交报价初稿)', now + dt.timedelta(days=1), 1),
|
|
+ ]
|
|
+ exist_wp = api(A, 'GET', '/api/opportunity/workplan/list', params={'oppId': a2},
|
|
+ step='workplan list') or []
|
|
+ for wp_content, wp_deadline, wp_status in wp_defs:
|
|
+ hit = next((r for r in exist_wp if r.get('planContent') == wp_content), None)
|
|
+ dl = wp_deadline.strftime('%Y-%m-%d %H:%M:%S')
|
|
+ if hit:
|
|
+ upd = {'id': hit['id'], 'deadline': dl}
|
|
+ if hit.get('planStatus') != wp_status:
|
|
+ upd['planStatus'] = wp_status
|
|
+ if api_void(A, '/api/opportunity/workplan/update', form=upd,
|
|
+ step=f'workplan 校准 {wp_content[:14]}'):
|
|
+ print(f' ⊘ 工作计划已在册 opp={a2} ← {wp_content[:18]}(deadline/状态校准)')
|
|
+ else:
|
|
+ d = api(A, 'POST', '/api/opportunity/workplan/add',
|
|
+ form={'oppId': a2, 'planContent': wp_content, 'deadline': dl},
|
|
+ step=f'workplan add {wp_content[:14]}')
|
|
+ if d is not None:
|
|
+ if wp_status == 1:
|
|
+ api_void(A, '/api/opportunity/workplan/update',
|
|
+ form={'id': d, 'planStatus': 1}, step='workplan 登记完成')
|
|
+ hit = {'id': d}
|
|
+ print(f' ✔ 工作计划 opp={a2} ← {wp_content[:18]}(行 id={d},status={wp_status})')
|
|
+ if hit:
|
|
+ manifest['workplans'].append({'oppId': str(a2), 'id': str(hit['id']),
|
|
+ 'content': wp_content, 'status': wp_status})
|
|
+ a2_row = row_of('a2')
|
|
+ if a2_row:
|
|
+ a2_row['samples'].append('工作计划×3(逾期/临期/已完成,票 12 Tab 数据源)')
|
|
+
|
|
# --- 关注×1:A 关注 B 的一条 ---
|
|
if b1:
|
|
if api_void(A, '/api/opportunity/focus', params={'oppId': b1}, step='focus'):
|
|
@@ -748,6 +787,7 @@ def write_outputs():
|
|
md = f"""# 商机 E2E · 测试数据清单(seed-data-manifest)
|
|
|
|
> 票 `11-testdata-refresh` 刷新 · 20260830 · 由 `seed-opportunity.py` 生成(幂等可重跑,重跑=先清后造)。
|
|
+> 票 `12-workplan-tab` 增补工作计划样例 · 20260831。
|
|
> 机读版:`seed-manifest.json`。机读/人读不一致时以 json 为准。
|
|
|
|
## 账号矩阵(debug token 用 userId)
|
|
@@ -778,6 +818,7 @@ def write_outputs():
|
|
- 自定义视图:{len(manifest['saved_views'])} 条(B 名下,scopeKey=opportunity)
|
|
- 关联客户:API 造 {len(manifest['customers'])} 行(票 04 customer/add;6 条推进中商机每条 2 个恰 1 主要,a2 另含 set-primary 切换剧情)
|
|
- 团队成员:API 造 {len(manifest['team_members'])} 条(票 04 team/add;a2/b1 完整团队:负责人+方案/现场/报价角色成员)
|
|
+- 工作计划:API 造 {len(manifest['workplans'])} 条(票 12 workplan/add+update;挂 a2:逾期/临期/已完成三形态,逾期=未完成且 now>deadline 前端实时判)
|
|
- 禁领回归:b4 领取实测 66014(冠军团队专用规则 allowFreeClaim=0);a4 领取放行对照(走通用规则)
|
|
- 长期暂缓样例:a3(预期重启 2027-01-15);b3 近期重启对照(2026-10-15)
|
|
- 附件:MinIO SK 未配齐,本次未造(票 07 F08 补)
|
|
|
|
|
|
### PART 3: untracked NEW FILE (full content): crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityWorkPlanAddDTO.java
|
|
package com.crm.opportunity.domain.dto;
|
|
|
|
import com.crm.base.domain.dto.BaseDTO;
|
|
import com.crm.opportunity.domain.entity.OpportunityWorkPlan;
|
|
import io.swagger.v3.oas.annotations.media.Schema;
|
|
import lombok.Data;
|
|
import lombok.EqualsAndHashCode;
|
|
import org.springframework.format.annotation.DateTimeFormat;
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
/**
|
|
* 新增工作计划写入参(票 12,精简 A1X:商机详情工作计划 Tab)。
|
|
*
|
|
* <p>仅商机侧子表 {@code opportunity_work_plan}(全量 A1X 独立模块仍挂起,P1-4)。
|
|
* 逾期实时计算不落库:planStatus=0 且 now>deadline 即逾期,由前端按 deadline 实时判。</p>
|
|
*/
|
|
@Data
|
|
@EqualsAndHashCode(callSuper = true)
|
|
@Schema(description = "新增工作计划入参")
|
|
public class OpportunityWorkPlanAddDTO extends BaseDTO {
|
|
|
|
@Schema(description = "所属商机ID")
|
|
private Long oppId;
|
|
|
|
@Schema(description = "计划内容(必填,≤1000 字)")
|
|
private String planContent;
|
|
|
|
@Schema(description = "截止时间(可选;判临期/逾期依据)")
|
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
|
private LocalDateTime deadline;
|
|
|
|
/** 显式映射(票 04 教训:BeanUtils 按名复制静默丢字段,字段集小直接显式 set) */
|
|
public OpportunityWorkPlan toEntity() {
|
|
OpportunityWorkPlan entity = new OpportunityWorkPlan();
|
|
entity.setOppId(this.getOppId());
|
|
entity.setPlanContent(this.getPlanContent());
|
|
entity.setDeadline(this.getDeadline());
|
|
return entity;
|
|
}
|
|
}
|
|
|
|
|
|
### PART 3: untracked NEW FILE (full content): crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityWorkPlanUpdateDTO.java
|
|
package com.crm.opportunity.domain.dto;
|
|
|
|
import com.crm.base.domain.dto.BaseDTO;
|
|
import io.swagger.v3.oas.annotations.media.Schema;
|
|
import lombok.Data;
|
|
import lombok.EqualsAndHashCode;
|
|
import org.springframework.format.annotation.DateTimeFormat;
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
/**
|
|
* 编辑工作计划写入参(票 12,部分更新:未传字段不动,落库走 LambdaUpdateWrapper 显式 set——
|
|
* 票 06 实体 `= ""` 初始化 + MP NOT_NULL 策略整实体 update 清字段 bug 的既有教训)。
|
|
*
|
|
* <p>登记完成语义:planStatus 0→1 服务端回填 finishTime=now;1→0 取消完成并清 finishTime。
|
|
* planStatus 值域封闭 0/1,其余拒 66001。</p>
|
|
*/
|
|
@Data
|
|
@EqualsAndHashCode(callSuper = true)
|
|
@Schema(description = "编辑工作计划入参")
|
|
public class OpportunityWorkPlanUpdateDTO extends BaseDTO {
|
|
|
|
@Schema(description = "工作计划行ID(必填)")
|
|
private Long id;
|
|
|
|
@Schema(description = "计划内容(可选;传入则整体替换)")
|
|
private String planContent;
|
|
|
|
@Schema(description = "截止时间(可选)")
|
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
|
private LocalDateTime deadline;
|
|
|
|
@Schema(description = "计划状态(可选):0未完成 1已完成;1 服务端回填 finishTime=now,0 清 finishTime")
|
|
private Integer planStatus;
|
|
}
|
|
|