### 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 补客户/团队写端点)。 * - *

六个子域:客户/跟进/勘察/附件/团队/日志。路由风格同项目其他 Controller: + *

子域:客户/跟进/勘察/附件/团队/日志/工作计划(票 12 补 workplan,精简 A1X)。 + * 路由风格同项目其他 Controller: * GET 查询,POST 写操作,id 走 @RequestParam,分页走 POST /page。 * 写入 ≥3 参按 ADR-0017 用 DTO 隐式表单绑定,≤2 参保持 @RequestParam。

*/ @@ -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> listWorkPlans(@RequestParam("oppId") Long oppId) { + return Result.success(subService.listWorkPlans(oppId)); + } + + @Operation(summary = "新增工作计划") + @PostMapping("/workplan/add") + public Result 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 updateWorkPlan(OpportunityWorkPlanUpdateDTO dto) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + subService.updateWorkPlan(dto, operatorId); + return Result.success(); + } + + @Operation(summary = "删除工作计划") + @PostMapping("/workplan/delete") + public Result 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 pageOplogs(Long oppId, long pageNum, long pageSize); + + // ==================== 工作计划 Tab(票 12,精简 A1X) ==================== + + /** 工作计划列表(存活行,createTime 升序;逾期实时计算不落库,前端按 deadline+planStatus 实时判) */ + List 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 listWorkPlans(Long oppId) { + return workPlanMapper.selectList(new LambdaQueryWrapper() + .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 wrapper = new LambdaUpdateWrapper() + .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() + .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 不可移除/不可换人/角色不可改)、普通成员换人直接替换 + 姓名快照服务端取。

*/ @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 cap = ArgumentCaptor.forClass(OpportunityWorkPlan.class); + verify(workPlanMapper).insert(cap.capture()); + assertThat(cap.getValue().getDeleteKey()).isEqualTo(0L); + assertThat(cap.getValue().getPlanContent()).isEqualTo("下周完成方案卡评审"); + ArgumentCaptor 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 captureUpdate() { + ArgumentCaptor> 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 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 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 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 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)。 * *

仅商机侧子表 {@code opportunity_work_plan}(全量 A1X 独立模块仍挂起,P1-4)。 * 逾期实时计算不落库:planStatus=0 且 now>deadline 即逾期,由前端按 deadline 实时判。

*/ @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 的既有教训)。 * *

登记完成语义:planStatus 0→1 服务端回填 finishTime=now;1→0 取消完成并清 finishTime。 * planStatus 值域封闭 0/1,其余拒 66001。

*/ @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; }