From 0d5ee628122508a46ab878dad4250144f3eb0fc0 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Thu, 27 Aug 2026 06:32:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E5=95=86=E6=9C=BA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8E=A5=E5=8F=A3=E6=95=B4=E6=94=B9=EF=BC=88?= =?UTF-8?q?=E7=A5=A801-06=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 票01: 修 W-02 NOT NULL 字段默认值 + 补 GET /detail + POST /edit - 票02: 状态机 11 个 HTTP 接口(claim/assign/pause/resume/release-pool/close/reopen/handover) - 票03: 详情 Tab 6 子域接口(客户/跟进/勘察/附件/团队/日志) - 票04: 协作+看板+访问上报 6 个接口 - 票05: 规则族 /versions 端点(阶段模板/方案卡模板/公海规则) - 票06: 回归验证 33 条接口全通 --- .../OpportunityCollabController.java | 84 ++++++++ .../OpportunityCreateController.java | 3 +- .../OpportunityDetailController.java | 40 ++++ .../controller/OpportunitySubController.java | 185 ++++++++++++++++++ .../OpportunityTransitionController.java | 167 ++++++++++++++++ .../domain/dto/CreateOpportunityRequest.java | 12 ++ .../domain/entity/Opportunity.java | 20 +- .../intake/OpportunityIntakeSpec.java | 4 + .../intake/impl/OpportunityIntakeImpl.java | 11 +- .../impl/OpportunityCreationPortImpl.java | 1 + .../service/IOpportunityCollabService.java | 25 +++ .../service/IOpportunityDetailService.java | 19 ++ .../service/IOpportunitySubService.java | 52 +++++ .../impl/OpportunityCollabServiceImpl.java | 132 +++++++++++++ .../impl/OpportunityCreateServiceImpl.java | 4 + .../impl/OpportunityDetailServiceImpl.java | 59 ++++++ .../impl/OpportunitySubServiceImpl.java | 169 ++++++++++++++++ .../impl/OpportunityIntakeImplTest.java | 10 +- .../OpportunityPoolRuleController.java | 16 ++ ...portunitySchemeCardTemplateController.java | 15 ++ .../OpportunityStageTemplateController.java | 18 ++ 21 files changed, 1027 insertions(+), 19 deletions(-) create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityDetailController.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityTransitionController.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityDetailService.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityDetailServiceImpl.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java new file mode 100644 index 0000000..34bf5d2 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java @@ -0,0 +1,84 @@ +package com.crm.opportunity.controller; + +import com.crm.base.domain.result.Result; +import com.crm.base.security.SecurityUtils; +import com.crm.opportunity.service.IOpportunityCollabService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 商机协作接口:关注/取关/访问上报/看板(票 04 整改,薄适配层)。 + */ +@Tag(name = "商机管理/协作") +@RestController +@RequestMapping("/api/opportunity") +@RequiredArgsConstructor +public class OpportunityCollabController { + + private final IOpportunityCollabService collabService; + + // ==================== 关注 ==================== + + @Operation(summary = "关注商机") + @PostMapping("/focus") + public Result focus(@RequestParam("oppId") Long oppId) { + Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); + collabService.focus(oppId, userId); + return Result.success(); + } + + @Operation(summary = "取关商机") + @PostMapping("/unfocus") + public Result unfocus(@RequestParam("oppId") Long oppId) { + Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); + collabService.unfocus(oppId, userId); + return Result.success(); + } + + @Operation(summary = "批量关注") + @PostMapping("/focus-batch") + public Result focusBatch(@RequestParam("ids") List ids) { + Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); + for (Long id : ids) { + collabService.focus(id, userId); + } + return Result.success(); + } + + // ==================== 访问上报 ==================== + + @Operation(summary = "上报商机访问(打开详情时调用)") + @PostMapping("/view-touch") + public Result viewTouch(@RequestParam("oppId") Long oppId) { + Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); + collabService.touch(oppId, userId); + return Result.success(); + } + + // ==================== 看板 ==================== + + @Operation(summary = "看板各阶段数量+金额汇总") + @PostMapping("/board/stage-summary") + public Result boardStageSummary( + @RequestParam(value = "stageTemplateId", required = false) Long stageTemplateId) { + Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); + return Result.success(collabService.boardStageSummary(stageTemplateId, userId)); + } + + @Operation(summary = "看板单阶段卡片列表") + @PostMapping("/board/cards") + public Result boardCards( + @RequestParam("stageId") Long stageId, + @RequestParam(value = "offset", defaultValue = "0") int offset, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); + return Result.success(collabService.boardCards(stageId, offset, limit, userId)); + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java index 252ee89..c139513 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java @@ -10,7 +10,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -32,7 +31,7 @@ public class OpportunityCreateController { @Operation(summary = "新建商机(oppSource=线索转入 时须选关联线索)") @PostMapping - public Result create(@RequestBody CreateOpportunityRequest request) { + public Result create(CreateOpportunityRequest request) { Long ownerUserId = Long.valueOf(SecurityUtils.getRequiredUserId()); return Result.success(createService.createOpportunity(request, ownerUserId)); } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityDetailController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityDetailController.java new file mode 100644 index 0000000..af365ae --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityDetailController.java @@ -0,0 +1,40 @@ +package com.crm.opportunity.controller; + +import com.crm.base.domain.result.Result; +import com.crm.opportunity.domain.dto.OpportunityDTO; +import com.crm.opportunity.service.IOpportunityDetailService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 商机详情 & 编辑接口(薄适配层,票 01 整改)。 + * + *

路由风格与线索模块对齐:全 GET/POST,动词后缀,id 走 @RequestParam。

+ */ +@Tag(name = "商机管理/商机详情") +@RestController +@RequestMapping("/api/opportunity") +@RequiredArgsConstructor +public class OpportunityDetailController { + + private final IOpportunityDetailService detailService; + + @Operation(summary = "商机详情") + @GetMapping("/detail") + public Result detail(@RequestParam("id") Long id) { + return Result.success(detailService.getDetail(id)); + } + + @Operation(summary = "编辑商机(非终态可编辑;全量字段覆盖)") + @PostMapping("/edit") + public Result edit(OpportunityDTO dto) { + detailService.edit(dto); + return Result.success(); + } +} 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 new file mode 100644 index 0000000..bc3eee5 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java @@ -0,0 +1,185 @@ +package com.crm.opportunity.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.crm.base.domain.result.Result; +import com.crm.base.security.SecurityUtils; +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.service.IOpportunitySubService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 商机详情 Tab 子域接口(票 03 整改,薄适配层)。 + * + *

六个子域:客户/跟进/勘察/附件/团队/日志。路由风格同项目其他 Controller: + * GET 查询,POST 写操作,id 走 @RequestParam,分页走 POST /page。

+ */ +@Tag(name = "商机管理/详情子域") +@RestController +@RequestMapping("/api/opportunity") +@RequiredArgsConstructor +public class OpportunitySubController { + + private final IOpportunitySubService subService; + + // ==================== 客户 Tab ==================== + + @Operation(summary = "客户列表") + @GetMapping("/customer/list") + public Result> listCustomers(@RequestParam("oppId") Long oppId) { + return Result.success(subService.listCustomers(oppId)); + } + + // ==================== 跟进 Tab ==================== + + @Operation(summary = "跟进记录分页") + @PostMapping("/follow/page") + public Result> pageFollows( + @RequestParam("oppId") Long oppId, + @RequestParam(value = "pageNum", defaultValue = "1") long pageNum, + @RequestParam(value = "pageSize", defaultValue = "10") long pageSize) { + return Result.success(subService.pageFollows(oppId, pageNum, pageSize)); + } + + @Operation(summary = "新增跟进记录") + @PostMapping("/follow/add") + public Result addFollow( + @RequestParam("oppId") Long oppId, + @RequestParam("followContent") String followContent, + @RequestParam("followTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime followTime, + @RequestParam("followWay") String followWay, + @RequestParam("customerId") Long customerId, + @RequestParam(value = "contactId", required = false) Long contactId, + @RequestParam(value = "resultTag", required = false) String resultTag, + @RequestParam(value = "nextFollowTime", required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime nextFollowTime) { + OpportunityFollow follow = new OpportunityFollow(); + follow.setOppId(oppId); + follow.setFollowContent(followContent); + follow.setFollowTime(followTime); + follow.setFollowWay(followWay); + follow.setCustomerId(customerId); + follow.setContactId(contactId); + follow.setResultTag(resultTag); + follow.setNextFollowTime(nextFollowTime); + return Result.success(subService.addFollow(follow)); + } + + @Operation(summary = "删除跟进记录") + @PostMapping("/follow/delete") + public Result deleteFollow(@RequestParam("id") Long id) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + subService.deleteFollow(id, operatorId); + return Result.success(); + } + + // ==================== 勘察 Tab ==================== + + @Operation(summary = "勘察记录分页") + @PostMapping("/site-survey/page") + public Result> pageSiteSurveys( + @RequestParam("oppId") Long oppId, + @RequestParam(value = "pageNum", defaultValue = "1") long pageNum, + @RequestParam(value = "pageSize", defaultValue = "10") long pageSize) { + return Result.success(subService.pageSiteSurveys(oppId, pageNum, pageSize)); + } + + @Operation(summary = "新增勘察记录") + @PostMapping("/site-survey/add") + public Result addSiteSurvey( + @RequestParam("oppId") Long oppId, + @RequestParam("surveyDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate surveyDate, + @RequestParam("engineerUserId") Long engineerUserId, + @RequestParam("applyWay") String applyWay, + @RequestParam("surveySeq") String surveySeq, + @RequestParam(value = "applyNo", required = false) String applyNo, + @RequestParam(value = "surveyDesc", required = false) String surveyDesc) { + OpportunitySiteSurvey survey = new OpportunitySiteSurvey(); + survey.setOppId(oppId); + survey.setSurveyDate(surveyDate); + survey.setEngineerUserId(engineerUserId); + survey.setApplyWay(applyWay); + survey.setSurveySeq(surveySeq); + survey.setApplyNo(applyNo); + survey.setSurveyDesc(surveyDesc); + return Result.success(subService.addSiteSurvey(survey)); + } + + @Operation(summary = "删除勘察记录") + @PostMapping("/site-survey/delete") + public Result deleteSiteSurvey(@RequestParam("id") Long id) { + subService.deleteSiteSurvey(id); + return Result.success(); + } + + // ==================== 附件 Tab ==================== + + @Operation(summary = "附件列表") + @GetMapping("/attachment/list") + public Result> listAttachments( + @RequestParam("oppId") Long oppId, + @RequestParam(value = "bizType", required = false) String bizType) { + return Result.success(subService.listAttachments(oppId, bizType)); + } + + @Operation(summary = "新增附件") + @PostMapping("/attachment/add") + public Result addAttachment( + @RequestParam("oppId") Long oppId, + @RequestParam("fileId") Long fileId, + @RequestParam(value = "bizType", required = false) String bizType, + @RequestParam(value = "bizId", required = false) Long bizId, + @RequestParam(value = "materialType", required = false) String materialType, + @RequestParam(value = "remark", required = false) String remark) { + OpportunityAttachment att = new OpportunityAttachment(); + att.setOppId(oppId); + att.setFileId(fileId); + att.setBizType(bizType); + att.setBizId(bizId); + att.setMaterialType(materialType); + att.setRemark(remark); + return Result.success(subService.addAttachment(att)); + } + + @Operation(summary = "删除附件") + @PostMapping("/attachment/delete") + public Result deleteAttachment(@RequestParam("id") Long id) { + subService.deleteAttachment(id); + return Result.success(); + } + + // ==================== 团队 Tab ==================== + + @Operation(summary = "团队成员列表") + @GetMapping("/team/list") + public Result> listTeamMembers(@RequestParam("oppId") Long oppId) { + return Result.success(subService.listTeamMembers(oppId)); + } + + // ==================== 日志 Tab ==================== + + @Operation(summary = "操作日志分页") + @PostMapping("/oplog/page") + public Result> pageOplogs( + @RequestParam("oppId") Long oppId, + @RequestParam(value = "pageNum", defaultValue = "1") long pageNum, + @RequestParam(value = "pageSize", defaultValue = "10") long pageSize) { + return Result.success(subService.pageOplogs(oppId, pageNum, pageSize)); + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityTransitionController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityTransitionController.java new file mode 100644 index 0000000..508eaa8 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityTransitionController.java @@ -0,0 +1,167 @@ +package com.crm.opportunity.controller; + +import com.crm.base.domain.result.Result; +import com.crm.base.security.SecurityUtils; +import com.crm.opportunity.state.OpportunityTransition; +import com.crm.opportunity.state.OpportunityTransitionCmd; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +/** + * 商机状态机 HTTP 入口(票 02 整改,薄适配层)。 + * + *

路由风格与线索模块对齐:全 POST,动词路径,id 走 @RequestParam。 + * Controller 只做:取当前用户 + 组装 Cmd + 调 {@link OpportunityTransition#execute}。 + * 快照字段(ownerNameSnapshot/ownerDeptId)由前端传入或由上层服务预查,本期从请求参数取。

+ */ +@Tag(name = "商机管理/商机流转") +@RestController +@RequestMapping("/api/opportunity") +@RequiredArgsConstructor +public class OpportunityTransitionController { + + private final OpportunityTransition transition; + + @Operation(summary = "领取商机(待领取→推进中,E1)") + @PostMapping("/claim") + public Result claim( + @RequestParam("id") Long id, + @RequestParam(value = "ownerNameSnapshot", required = false) String ownerNameSnapshot, + @RequestParam(value = "ownerDeptId", required = false) Long ownerDeptId) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.ClaimCmd( + operatorId, ownerNameSnapshot, ownerDeptId)); + return Result.success(); + } + + @Operation(summary = "批量领取(待领取→推进中,E1)") + @PostMapping("/claim-batch") + public Result claimBatch( + @RequestParam("ids") List ids, + @RequestParam(value = "ownerNameSnapshot", required = false) String ownerNameSnapshot, + @RequestParam(value = "ownerDeptId", required = false) Long ownerDeptId) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + for (Long id : ids) { + transition.execute(id, new OpportunityTransitionCmd.ClaimCmd( + operatorId, ownerNameSnapshot, ownerDeptId)); + } + return Result.success(); + } + + @Operation(summary = "分配商机给指定销售(待领取→推进中,E2)") + @PostMapping("/assign") + public Result assign( + @RequestParam("id") Long id, + @RequestParam("userId") Long userId, + @RequestParam(value = "ownerNameSnapshot", required = false) String ownerNameSnapshot, + @RequestParam(value = "ownerDeptId", required = false) Long ownerDeptId) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.AssignToUserCmd( + operatorId, userId, ownerNameSnapshot, ownerDeptId)); + return Result.success(); + } + + @Operation(summary = "批量分配商机(待领取→推进中,E2)") + @PostMapping("/assign-batch") + public Result assignBatch( + @RequestParam("ids") List ids, + @RequestParam("userId") Long userId, + @RequestParam(value = "ownerNameSnapshot", required = false) String ownerNameSnapshot, + @RequestParam(value = "ownerDeptId", required = false) Long ownerDeptId) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + for (Long id : ids) { + transition.execute(id, new OpportunityTransitionCmd.AssignToUserCmd( + operatorId, userId, ownerNameSnapshot, ownerDeptId)); + } + return Result.success(); + } + + @Operation(summary = "抛公海(推进中→待领取,E3)") + @PostMapping("/release-pool") + public Result releasePool( + @RequestParam("id") Long id, + @RequestParam(value = "poolReason", required = false) String poolReason) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.ReleaseToPoolCmd(operatorId, poolReason)); + return Result.success(); + } + + @Operation(summary = "暂缓(推进中→暂缓中,E4)") + @PostMapping("/pause") + public Result pause( + @RequestParam("id") Long id, + @RequestParam(value = "pauseReason", required = false) String pauseReason, + @RequestParam(value = "pauseExpectedRestartDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pauseExpectedRestartDate, + @RequestParam(value = "pauseRemark", required = false) String pauseRemark) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.PauseCmd( + operatorId, pauseReason, pauseExpectedRestartDate, pauseRemark)); + return Result.success(); + } + + @Operation(summary = "取消暂缓(暂缓中→推进中,E5)") + @PostMapping("/resume") + public Result resume(@RequestParam("id") Long id) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.ResumeCmd(operatorId)); + return Result.success(); + } + + @Operation(summary = "关闭商机(推进中/暂缓中→已关闭,E6)") + @PostMapping("/close") + public Result close( + @RequestParam("id") Long id, + @RequestParam(value = "closeReason", required = false) String closeReason, + @RequestParam(value = "closeRemark", required = false) String closeRemark) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.CloseCmd(operatorId, closeReason, closeRemark)); + return Result.success(); + } + + @Operation(summary = "批量关闭(推进中/暂缓中→已关闭,E6)") + @PostMapping("/close-batch") + public Result closeBatch( + @RequestParam("ids") List ids, + @RequestParam(value = "closeReason", required = false) String closeReason, + @RequestParam(value = "closeRemark", required = false) String closeRemark) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + for (Long id : ids) { + transition.execute(id, new OpportunityTransitionCmd.CloseCmd(operatorId, closeReason, closeRemark)); + } + return Result.success(); + } + + @Operation(summary = "重启商机(已关闭→推进中,E7)") + @PostMapping("/reopen") + public Result reopen( + @RequestParam("id") Long id, + @RequestParam(value = "reopenReason", required = false) String reopenReason) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.ReopenCmd(operatorId, reopenReason)); + return Result.success(); + } + + @Operation(summary = "移交商机(推进中/暂缓中自环换owner,E9)") + @PostMapping("/handover") + public Result handover( + @RequestParam("id") Long id, + @RequestParam("expectedOwnerUserId") Long expectedOwnerUserId, + @RequestParam("newOwnerUserId") Long newOwnerUserId, + @RequestParam(value = "ownerNameSnapshot", required = false) String ownerNameSnapshot, + @RequestParam(value = "ownerDeptId", required = false) Long ownerDeptId) { + Long operatorId = Long.valueOf(SecurityUtils.getRequiredUserId()); + transition.execute(id, new OpportunityTransitionCmd.HandoverCmd( + operatorId, expectedOwnerUserId, newOwnerUserId, ownerNameSnapshot, ownerDeptId)); + return Result.success(); + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java index 966c19d..9f594b0 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java @@ -21,9 +21,21 @@ public class CreateOpportunityRequest { /** 商机名称(必填)。 */ private String opportunityName; + /** 商机类型字典 code。 */ + private String oppType; + /** 行业字典 code。 */ private String industryCode; + /** 招标形式字典 code。 */ + private String bidForm; + + /** 项目属地字典 code(本地/异地)。 */ + private String localityType; + + /** 甲方是否明确 0/1(默认 0)。 */ + private Integer partyAClear; + /** 甲方。 */ private String partyA; diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java index 39fda96..9168e22 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java @@ -43,31 +43,31 @@ public class Opportunity extends BaseEntity { @Comment("商机名称") @Column(columnDefinition = "varchar(200) not null comment '商机名称'") - private String oppName; + private String oppName = ""; @Comment("商机来源字典 code(渠道归因,线索转入时默认置「线索转入」)") @Column(columnDefinition = "varchar(64) not null comment '商机来源字典code'") - private String oppSource; + private String oppSource = ""; @Comment("商机类型字典 code") @Column(columnDefinition = "varchar(64) not null comment '商机类型字典code'") - private String oppType; + private String oppType = ""; @Comment("行业字典 code") @Column(columnDefinition = "varchar(64) not null comment '行业字典code'") - private String industryCode; + private String industryCode = ""; @Comment("招标形式字典 code") @Column(columnDefinition = "varchar(64) not null comment '招标形式字典code'") - private String bidForm; + private String bidForm = ""; @Comment("项目属地字典 code(本地/异地分类标签,非地理坐标)") @Column(columnDefinition = "varchar(64) not null comment '项目属地字典code'") - private String localityType; + private String localityType = ""; @Comment("甲方是否明确 0/1") - @Column(columnDefinition = "tinyint not null comment '甲方是否明确'") - private Integer partyAClear; + @Column(columnDefinition = "tinyint not null default 0 comment '甲方是否明确'") + private Integer partyAClear = 0; @Comment("甲方名称(party_a_clear=1 时必填)") @Column(columnDefinition = "varchar(200) comment '甲方名称'") @@ -75,11 +75,11 @@ public class Opportunity extends BaseEntity { @Comment("省份国标 code(GB/T 2260,6位)") @Column(columnDefinition = "varchar(12) not null comment '省份国标code'") - private String provinceCode; + private String provinceCode = ""; @Comment("市/区国标 code(对称 lead 约定:市码或区/县码均可,不单建 district_code)") @Column(columnDefinition = "varchar(12) not null comment '市/区国标code'") - private String cityCode; + private String cityCode = ""; @Comment("备注") @Column(columnDefinition = "text comment '备注'") diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java index 27900cb..37391b5 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java @@ -32,6 +32,10 @@ public record OpportunityIntakeSpec( IntakeSource source, String oppSource, String opportunityName, + String oppType, + String bidForm, + String localityType, + Integer partyAClear, String industryCode, String partyA, String provinceCode, diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java index 629a49c..6dc9dd3 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java @@ -93,11 +93,16 @@ public class OpportunityIntakeImpl implements OpportunityIntake { opp.setOppName(spec.opportunityName()); // 商机来源:LEAD_CONVERT 恒 opp_source_01(adapter 已按枚举默认填入 spec),DIRECT 为用户所选。 opp.setOppSource(spec.oppSource()); - opp.setIndustryCode(spec.industryCode()); + // NOT NULL 字段:前端未传时给空串/0 兜底,避免 DB 插入报错 + opp.setOppType(spec.oppType() != null ? spec.oppType() : ""); + opp.setBidForm(spec.bidForm() != null ? spec.bidForm() : ""); + opp.setLocalityType(spec.localityType() != null ? spec.localityType() : ""); + opp.setPartyAClear(spec.partyAClear() != null ? spec.partyAClear() : 0); + opp.setProvinceCode(spec.provinceCode() != null ? spec.provinceCode() : ""); + opp.setCityCode(spec.cityCode() != null ? spec.cityCode() : ""); + opp.setIndustryCode(spec.industryCode() != null ? spec.industryCode() : ""); opp.setPartyA(spec.partyA()); opp.setRemark(spec.remark()); - opp.setProvinceCode(spec.provinceCode()); - opp.setCityCode(spec.cityCode()); opp.setOwnerUserId(spec.ownerUserId()); opp.setOwnerDeptId(spec.ownerDeptId()); opp.setCreatorUserId(spec.ownerUserId()); diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java index 91f9f6b..020d256 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java @@ -37,6 +37,7 @@ public class OpportunityCreationPortImpl implements OpportunityCreationPort { IntakeSource.LEAD_CONVERT, IntakeSource.LEAD_CONVERT.defaultOppSource(), cmd.opportunityName(), + null, null, null, null, // oppType/bidForm/localityType/partyAClear 线索转入不传,底层兑底 cmd.industryCode(), cmd.partyA(), cmd.provinceCode(), diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java new file mode 100644 index 0000000..ddf47ab --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java @@ -0,0 +1,25 @@ +package com.crm.opportunity.service; + +import java.util.List; +import java.util.Map; + +/** + * 商机协作服务:关注/取关/访问上报/看板(票 04 整改)。 + */ +public interface IOpportunityCollabService { + + /** 关注(幂等:已关注则忽略) */ + void focus(Long oppId, Long userId); + + /** 取关(幂等:未关注则忽略) */ + void unfocus(Long oppId, Long userId); + + /** 访问上报(upsert last_view_time) */ + void touch(Long oppId, Long userId); + + /** 看板阶段汇总:List<{stageId, stageName, count, totalAmount}> */ + List> boardStageSummary(Long stageTemplateId, Long userId); + + /** 看板单阶段卡片列表(分页 offset/limit) */ + List> boardCards(Long stageId, int offset, int limit, Long userId); +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityDetailService.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityDetailService.java new file mode 100644 index 0000000..8f5fb52 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityDetailService.java @@ -0,0 +1,19 @@ +package com.crm.opportunity.service; + +import com.crm.opportunity.domain.dto.OpportunityDTO; + +/** + * 商机详情 & 编辑服务(票 01 整改)。 + */ +public interface IOpportunityDetailService { + + /** + * 商机详情(GET /api/opportunity/detail?id=)。 + */ + OpportunityDTO getDetail(Long id); + + /** + * 编辑商机(POST /api/opportunity/edit)。 + */ + void edit(OpportunityDTO dto); +} 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 new file mode 100644 index 0000000..24316b2 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java @@ -0,0 +1,52 @@ +package com.crm.opportunity.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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; + +/** + * 商机详情 Tab 子域查询服务(票 03 整改)。 + * 六个子域:客户、跟进、勘察、附件、团队、日志。 + */ +public interface IOpportunitySubService { + + /** 客户列表(全量,通常不多) */ + java.util.List listCustomers(Long oppId); + + /** 跟进记录分页 */ + Page pageFollows(Long oppId, long pageNum, long pageSize); + + /** 新增跟进 */ + Long addFollow(OpportunityFollow follow); + + /** 删除跟进(软删) */ + void deleteFollow(Long followId, Long operatorId); + + /** 勘察记录分页 */ + Page pageSiteSurveys(Long oppId, long pageNum, long pageSize); + + /** 新增勘察记录 */ + Long addSiteSurvey(OpportunitySiteSurvey survey); + + /** 删除勘察记录(软删) */ + void deleteSiteSurvey(Long surveyId); + + /** 附件列表(按 oppId + 可选 bizType) */ + java.util.List listAttachments(Long oppId, String bizType); + + /** 新增附件 */ + Long addAttachment(OpportunityAttachment attachment); + + /** 删除附件(软删) */ + void deleteAttachment(Long attachmentId); + + /** 团队成员列表(全量) */ + java.util.List listTeamMembers(Long oppId); + + /** 操作日志分页 */ + Page pageOplogs(Long oppId, long pageNum, long pageSize); +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java new file mode 100644 index 0000000..79cc32b --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java @@ -0,0 +1,132 @@ +package com.crm.opportunity.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.domain.entity.OpportunityFocus; +import com.crm.opportunity.domain.entity.OpportunityViewLog; +import com.crm.opportunity.mapper.OpportunityFocusMapper; +import com.crm.opportunity.mapper.OpportunityMapper; +import com.crm.opportunity.mapper.OpportunityViewLogMapper; +import com.crm.opportunity.service.IOpportunityCollabService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 商机协作服务实现(票 04 整改)。 + */ +@Service +@RequiredArgsConstructor +public class OpportunityCollabServiceImpl implements IOpportunityCollabService { + + private final OpportunityFocusMapper focusMapper; + private final OpportunityViewLogMapper viewLogMapper; + private final OpportunityMapper oppMapper; + + // ==================== 关注 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void focus(Long oppId, Long userId) { + boolean exists = focusMapper.exists(new LambdaQueryWrapper() + .eq(OpportunityFocus::getOpportunityId, oppId) + .eq(OpportunityFocus::getUserId, userId)); + if (!exists) { + OpportunityFocus focus = new OpportunityFocus(); + focus.setOpportunityId(oppId); + focus.setUserId(userId); + focus.setFocusTime(LocalDateTime.now()); + focusMapper.insert(focus); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unfocus(Long oppId, Long userId) { + focusMapper.delete(new LambdaQueryWrapper() + .eq(OpportunityFocus::getOpportunityId, oppId) + .eq(OpportunityFocus::getUserId, userId)); + } + + // ==================== 访问上报 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void touch(Long oppId, Long userId) { + OpportunityViewLog existing = viewLogMapper.selectOne(new LambdaQueryWrapper() + .eq(OpportunityViewLog::getOpportunityId, oppId) + .eq(OpportunityViewLog::getUserId, userId)); + if (existing != null) { + viewLogMapper.update(null, new LambdaUpdateWrapper() + .eq(OpportunityViewLog::getId, existing.getId()) + .set(OpportunityViewLog::getLastViewTime, LocalDateTime.now())); + } else { + OpportunityViewLog log = new OpportunityViewLog(); + log.setOpportunityId(oppId); + log.setUserId(userId); + log.setLastViewTime(LocalDateTime.now()); + viewLogMapper.insert(log); + } + } + + // ==================== 看板 ==================== + + @Override + public List> boardStageSummary(Long stageTemplateId, Long userId) { + // 按 current_stage_id 分组统计:数量 + 金额汇总 + // 简单实现:查所有推进中商机,按 stageId 聚合 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Opportunity::getOppStatus, 2); // 推进中 + if (stageTemplateId != null) { + wrapper.eq(Opportunity::getStageTemplateId, stageTemplateId); + } + List opps = oppMapper.selectList(wrapper + .select(Opportunity::getCurrentStageId, Opportunity::getProjectAmount)); + + // 聚合 + Map> grouped = new java.util.LinkedHashMap<>(); + for (Opportunity opp : opps) { + Long stageId = opp.getCurrentStageId(); + grouped.computeIfAbsent(stageId, k -> { + Map m = new HashMap<>(); + m.put("stageId", k); + m.put("count", 0); + m.put("totalAmount", java.math.BigDecimal.ZERO); + return m; + }); + Map m = grouped.get(stageId); + m.put("count", (int) m.get("count") + 1); + if (opp.getProjectAmount() != null) { + m.put("totalAmount", ((java.math.BigDecimal) m.get("totalAmount")).add(opp.getProjectAmount())); + } + } + return new ArrayList<>(grouped.values()); + } + + @Override + public List> boardCards(Long stageId, int offset, int limit, Long userId) { + List opps = oppMapper.selectList(new LambdaQueryWrapper() + .eq(Opportunity::getCurrentStageId, stageId) + .eq(Opportunity::getOppStatus, 2) + .last("LIMIT " + offset + "," + limit)); + List> result = new ArrayList<>(); + for (Opportunity opp : opps) { + Map card = new HashMap<>(); + card.put("id", opp.getId()); + card.put("oppName", opp.getOppName()); + card.put("ownerUserId", opp.getOwnerUserId()); + card.put("ownerNameSnapshot", opp.getOwnerNameSnapshot()); + card.put("projectAmount", opp.getProjectAmount()); + card.put("currentStageId", opp.getCurrentStageId()); + result.add(card); + } + return result; + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java index cda4e67..74f5190 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java @@ -58,6 +58,10 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService { IntakeSource.DIRECT, request.getOppSource(), request.getOpportunityName(), + request.getOppType(), + request.getBidForm(), + request.getLocalityType(), + request.getPartyAClear(), request.getIndustryCode(), request.getPartyA(), request.getProvinceCode(), diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityDetailServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityDetailServiceImpl.java new file mode 100644 index 0000000..95c9e38 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityDetailServiceImpl.java @@ -0,0 +1,59 @@ +package com.crm.opportunity.service.impl; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.crm.base.domain.exception.BusinessErrorException; +import com.crm.opportunity.domain.dto.OpportunityDTO; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.mapper.OpportunityMapper; +import com.crm.opportunity.service.IOpportunityDetailService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 商机详情 & 编辑实现(票 01 整改,薄适配层)。 + */ +@Service +@RequiredArgsConstructor +public class OpportunityDetailServiceImpl implements IOpportunityDetailService { + + private final OpportunityMapper oppMapper; + + @Override + public OpportunityDTO getDetail(Long id) { + Opportunity entity = oppMapper.selectById(id); + if (entity == null) { + throw new BusinessErrorException("商机不存在:id=" + id); + } + return OpportunityDTO.fromEntity(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void edit(OpportunityDTO dto) { + if (dto.getId() == null) { + throw new BusinessErrorException("编辑商机时 id 必填"); + } + Opportunity existing = oppMapper.selectById(dto.getId()); + if (existing == null) { + throw new BusinessErrorException("商机不存在:id=" + dto.getId()); + } + // 用 LambdaUpdateWrapper 明确指定更新字段,绕开 MP 乘观锁自动处理的 MetaObject 问题 + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(Opportunity::getId, dto.getId()); + Opportunity update = new Opportunity(); + if (dto.getOppName() != null) update.setOppName(dto.getOppName()); + if (dto.getOppSource() != null) update.setOppSource(dto.getOppSource()); + if (dto.getOppType() != null) update.setOppType(dto.getOppType()); + if (dto.getIndustryCode() != null) update.setIndustryCode(dto.getIndustryCode()); + if (dto.getBidForm() != null) update.setBidForm(dto.getBidForm()); + if (dto.getLocalityType() != null) update.setLocalityType(dto.getLocalityType()); + if (dto.getPartyAClear() != null) update.setPartyAClear(dto.getPartyAClear()); + if (dto.getPartyA() != null) update.setPartyA(dto.getPartyA()); + if (dto.getProvinceCode() != null) update.setProvinceCode(dto.getProvinceCode()); + if (dto.getCityCode() != null) update.setCityCode(dto.getCityCode()); + if (dto.getRemark() != null) update.setRemark(dto.getRemark()); + if (dto.getProjectAmount() != null) update.setProjectAmount(dto.getProjectAmount()); + oppMapper.update(update, wrapper); + } +} 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 new file mode 100644 index 0000000..20a3f8e --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java @@ -0,0 +1,169 @@ +package com.crm.opportunity.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.crm.base.domain.exception.BusinessErrorException; +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.mapper.OpportunityAttachmentMapper; +import com.crm.opportunity.mapper.OpportunityCustomerMapper; +import com.crm.opportunity.mapper.OpportunityFollowMapper; +import com.crm.opportunity.mapper.OpportunityOplogMapper; +import com.crm.opportunity.mapper.OpportunitySiteSurveyMapper; +import com.crm.opportunity.mapper.OpportunityTeamMapper; +import com.crm.opportunity.service.IOpportunitySubService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; + +/** + * 商机详情 Tab 子域服务实现(票 03 整改)。 + */ +@Service +@RequiredArgsConstructor +public class OpportunitySubServiceImpl implements IOpportunitySubService { + + private final OpportunityCustomerMapper customerMapper; + private final OpportunityFollowMapper followMapper; + private final OpportunitySiteSurveyMapper siteSurveyMapper; + private final OpportunityAttachmentMapper attachmentMapper; + private final OpportunityTeamMapper teamMapper; + private final OpportunityOplogMapper oplogMapper; + + // ==================== 客户 ==================== + + @Override + public List listCustomers(Long oppId) { + return customerMapper.selectList(new LambdaQueryWrapper() + .eq(OpportunityCustomer::getOpportunityId, oppId) + .eq(OpportunityCustomer::getDeleteKey, 0L) + .orderByDesc(OpportunityCustomer::getIsPrimaryIntended)); + } + + // ==================== 跟进 ==================== + + @Override + public Page pageFollows(Long oppId, long pageNum, long pageSize) { + return followMapper.selectPage(new Page<>(pageNum, pageSize), + new LambdaQueryWrapper() + .eq(OpportunityFollow::getOppId, oppId) + .eq(OpportunityFollow::getDeleteKey, 0L) + .orderByDesc(OpportunityFollow::getFollowTime)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long addFollow(OpportunityFollow follow) { + if (follow.getOppId() == null) throw new BusinessErrorException("oppId 必填"); + if (!StringUtils.hasText(follow.getFollowContent())) throw new BusinessErrorException("跟进内容必填"); + if (follow.getFollowTime() == null) throw new BusinessErrorException("跟进时间必填"); + if (!StringUtils.hasText(follow.getFollowWay())) throw new BusinessErrorException("跟进方式必填"); + if (follow.getCustomerId() == null) throw new BusinessErrorException("关联客户必填"); + follow.setDeleteKey(0L); + followMapper.insert(follow); + return follow.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFollow(Long followId, Long operatorId) { + OpportunityFollow follow = followMapper.selectById(followId); + if (follow == null) throw new BusinessErrorException("跟进记录不存在"); + followMapper.update(null, new LambdaUpdateWrapper() + .eq(OpportunityFollow::getId, followId) + .set(OpportunityFollow::getDeleteKey, followId) + .set(OpportunityFollow::getDeleted, 1)); + } + + // ==================== 勘察 ==================== + + @Override + public Page pageSiteSurveys(Long oppId, long pageNum, long pageSize) { + return siteSurveyMapper.selectPage(new Page<>(pageNum, pageSize), + new LambdaQueryWrapper() + .eq(OpportunitySiteSurvey::getOppId, oppId) + .orderByDesc(OpportunitySiteSurvey::getSurveyDate)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long addSiteSurvey(OpportunitySiteSurvey survey) { + if (survey.getOppId() == null) throw new BusinessErrorException("oppId 必填"); + if (survey.getSurveyDate() == null) throw new BusinessErrorException("勘察日期必填"); + if (survey.getEngineerUserId() == null) throw new BusinessErrorException("工程师必填"); + siteSurveyMapper.insert(survey); + return survey.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSiteSurvey(Long surveyId) { + OpportunitySiteSurvey survey = siteSurveyMapper.selectById(surveyId); + if (survey == null) throw new BusinessErrorException("勘察记录不存在"); + siteSurveyMapper.update(null, new LambdaUpdateWrapper() + .eq(OpportunitySiteSurvey::getId, surveyId) + .set(OpportunitySiteSurvey::getDeleted, 1)); + } + + // ==================== 附件 ==================== + + @Override + public List listAttachments(Long oppId, String bizType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(OpportunityAttachment::getOppId, oppId) + .eq(OpportunityAttachment::getDeleteKey, 0L); + if (StringUtils.hasText(bizType)) { + wrapper.eq(OpportunityAttachment::getBizType, bizType); + } + return attachmentMapper.selectList(wrapper.orderByDesc(OpportunityAttachment::getCreateTime)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long addAttachment(OpportunityAttachment attachment) { + if (attachment.getOppId() == null) throw new BusinessErrorException("oppId 必填"); + if (attachment.getFileId() == null) throw new BusinessErrorException("fileId 必填"); + attachment.setDeleteKey(0L); + attachmentMapper.insert(attachment); + return attachment.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAttachment(Long attachmentId) { + OpportunityAttachment att = attachmentMapper.selectById(attachmentId); + if (att == null) throw new BusinessErrorException("附件不存在"); + attachmentMapper.update(null, new LambdaUpdateWrapper() + .eq(OpportunityAttachment::getId, attachmentId) + .set(OpportunityAttachment::getDeleteKey, attachmentId) + .set(OpportunityAttachment::getDeleted, 1)); + } + + // ==================== 团队 ==================== + + @Override + public List listTeamMembers(Long oppId) { + return teamMapper.selectList(new LambdaQueryWrapper() + .eq(OpportunityTeam::getOpportunityId, oppId) + .eq(OpportunityTeam::getDeleteKey, 0L) + .orderByAsc(OpportunityTeam::getCreateTime)); + } + + // ==================== 操作日志 ==================== + + @Override + public Page pageOplogs(Long oppId, long pageNum, long pageSize) { + return oplogMapper.selectPage(new Page<>(pageNum, pageSize), + new LambdaQueryWrapper() + .eq(OpportunityOplog::getOppId, oppId) + .orderByDesc(OpportunityOplog::getOpTime)); + } +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java index 34c71e0..742d3c0 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java +++ b/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java @@ -67,16 +67,18 @@ class OpportunityIntakeImplTest { /** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01)。 */ private OpportunityIntakeSpec leadConvertSpec(Long customerId) { return new OpportunityIntakeSpec( - IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A", "ind01", "甲方X", - "110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID, + IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A", + null, null, null, null, + "ind01", "甲方X", "110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID, customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01"); } /** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索)。 */ private OpportunityIntakeSpec directSpec(String oppSource, Long customerId) { return new OpportunityIntakeSpec( - IntakeSource.DIRECT, oppSource, "商机B", "ind02", "甲方Y", - "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, + IntakeSource.DIRECT, oppSource, "商机B", + null, null, null, null, + "ind02", "甲方Y", "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, customerId, "意向客户名2", null, null, null, null); } diff --git a/crm-rule/src/main/java/com/crm/rule/controller/OpportunityPoolRuleController.java b/crm-rule/src/main/java/com/crm/rule/controller/OpportunityPoolRuleController.java index 13b32fc..0cec8de 100644 --- a/crm-rule/src/main/java/com/crm/rule/controller/OpportunityPoolRuleController.java +++ b/crm-rule/src/main/java/com/crm/rule/controller/OpportunityPoolRuleController.java @@ -4,7 +4,11 @@ import com.crm.base.domain.result.PageResult; import com.crm.base.domain.result.Result; import com.crm.rule.domain.dto.OpportunityPoolRuleDTO; import com.crm.rule.domain.param.OpportunityPoolRulePageParam; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.rule.domain.entity.OpportunityPoolRule; +import com.crm.rule.mapper.OpportunityPoolRuleMapper; import com.crm.rule.service.IOpportunityPoolRuleService; +import java.util.List; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -26,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; public class OpportunityPoolRuleController { private final IOpportunityPoolRuleService ruleService; + private final OpportunityPoolRuleMapper ruleMapper; @Operation(summary = "公海规则分页列表(按版本维度展示)") @PostMapping("/page") @@ -72,4 +77,15 @@ public class OpportunityPoolRuleController { ruleService.deleteDraft(id); return Result.success(); } + + @Operation(summary = "同族版本列表(按 ruleCode 查所有版本)") + @GetMapping("/versions") + public Result> versions(@RequestParam("id") Long id) { + OpportunityPoolRule r = ruleMapper.selectById(id); + if (r == null) return Result.success(java.util.List.of()); + return Result.success(ruleMapper.selectList( + new LambdaQueryWrapper() + .eq(OpportunityPoolRule::getRuleCode, r.getRuleCode()) + .orderByDesc(OpportunityPoolRule::getCreateTime))); + } } diff --git a/crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java b/crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java index 39d30d9..f392d8a 100644 --- a/crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java +++ b/crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java @@ -5,6 +5,9 @@ import com.crm.base.domain.result.Result; import com.crm.rule.domain.dto.OpportunitySchemeCardTemplateDTO; import com.crm.rule.domain.dto.OpportunitySchemeFieldDefDTO; import com.crm.rule.domain.param.OpportunitySchemeCardTemplatePageParam; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate; +import com.crm.rule.mapper.OpportunitySchemeCardTemplateMapper; import com.crm.rule.service.IOpportunitySchemeCardTemplateService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -30,6 +33,7 @@ import java.util.List; public class OpportunitySchemeCardTemplateController { private final IOpportunitySchemeCardTemplateService templateService; + private final OpportunitySchemeCardTemplateMapper schemeTemplateMapper; @Operation(summary = "方案卡模板分页列表(按版本维度展示)") @PostMapping("/page") @@ -82,4 +86,15 @@ public class OpportunitySchemeCardTemplateController { templateService.deleteDraft(id); return Result.success(); } + + @Operation(summary = "同族版本列表(按 templateCode 查所有版本)") + @GetMapping("/versions") + public Result> versions(@RequestParam("id") Long id) { + OpportunitySchemeCardTemplate t = schemeTemplateMapper.selectById(id); + if (t == null) return Result.success(List.of()); + return Result.success(schemeTemplateMapper.selectList( + new LambdaQueryWrapper() + .eq(OpportunitySchemeCardTemplate::getTemplateCode, t.getTemplateCode()) + .orderByDesc(OpportunitySchemeCardTemplate::getCreateTime))); + } } diff --git a/crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java b/crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java index 2f796d2..addb46f 100644 --- a/crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java +++ b/crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java @@ -4,6 +4,9 @@ import com.crm.base.domain.result.PageResult; import com.crm.base.domain.result.Result; import com.crm.rule.domain.dto.OpportunityStageTemplateDTO; import com.crm.rule.domain.param.OpportunityStageTemplatePageParam; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.rule.domain.entity.OpportunityStageTemplate; +import com.crm.rule.mapper.OpportunityStageTemplateMapper; import com.crm.rule.service.IOpportunityStageTemplateService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -14,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * 商机阶段模板接口(薄适配层,只调 {@link IOpportunityStageTemplateService},票 06) *

权限策略:不种子化 button 权限点(同公海规则),ApiPermissionInterceptor 对未注册 @@ -27,6 +32,7 @@ import org.springframework.web.bind.annotation.RestController; public class OpportunityStageTemplateController { private final IOpportunityStageTemplateService templateService; + private final OpportunityStageTemplateMapper templateMapper; @Operation(summary = "阶段模板分页列表(按版本维度展示)") @PostMapping("/page") @@ -73,4 +79,16 @@ public class OpportunityStageTemplateController { templateService.deleteDraft(id); return Result.success(); } + + @Operation(summary = "同族版本列表(按 templateCode 查所有版本)") + @GetMapping("/versions") + public Result> versions(@RequestParam("id") Long id) { + OpportunityStageTemplate t = templateMapper.selectById(id); + if (t == null) return Result.success(List.of()); + List list = templateMapper.selectList( + new LambdaQueryWrapper() + .eq(OpportunityStageTemplate::getTemplateCode, t.getTemplateCode()) + .orderByDesc(OpportunityStageTemplate::getCreateTime)); + return Result.success(list); + } }