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.

682 lines
35 KiB

7 days ago
### 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&gt;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;
}