package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.CslCustomer; import com.kaidi.oa.domain.CslProject; import com.kaidi.oa.domain.Opportunity; import com.kaidi.oa.repository.CslCustomerRepository; import com.kaidi.oa.repository.CslProjectRepository; import com.kaidi.oa.repository.OpportunityRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 设计研究中心 / 咨询可研院 —— CSL 专属客户关系管理(补审计 PARTIAL 缺口·模块5)。 * * 解决三项审计缺口: * 1. 无 CslCustomer 专属实体:建立 CSL 客户画像台账,含满意度评分/投诉次数/复购标记/显式项目关联; * 2. CSL 项目与 Bid/Opportunity 靠客户名前4字模糊匹配精度低:通过 linkProject 建立 latestCslProjectId 显式外键; * 3. 无 CSL 专项销售漏斗:GET /funnel 按商机阶段统计 CSL 专属转化率(仅统咨询类商机), * 与全局 CrmMarketingDashboard.funnel 区分,给出专项数据。 * * 写口(POST/PATCH/DELETE)已登记进 AuthInterceptor FINANCE_PREFIXES(见 sharedFileSnippets)。 * 读口已登记进 SENSITIVE_READ_PREFIXES(含 PII phone 字段)。 */ @RestController @RequestMapping("/api/oa/csl-customers") public class CslCustomerController { private final CslCustomerRepository cslCustomerRepo; private final CslProjectRepository cslProjectRepo; private final OpportunityRepository opportunityRepo; public CslCustomerController(CslCustomerRepository cslCustomerRepo, CslProjectRepository cslProjectRepo, OpportunityRepository opportunityRepo) { this.cslCustomerRepo = cslCustomerRepo; this.cslProjectRepo = cslProjectRepo; this.opportunityRepo = opportunityRepo; } // ===== CRUD ===== @GetMapping public ApiResp> list( @RequestParam(required = false) String grade, @RequestParam(required = false) String field, @RequestParam(required = false) String clientType, @RequestParam(required = false) String keyword, @RequestParam(required = false) Boolean repurchase) { if (repurchase != null && repurchase) { return ApiResp.ok(cslCustomerRepo.findByRepurchaseFlag(true)); } if (grade != null && !grade.isBlank()) { return ApiResp.ok(cslCustomerRepo.findByGrade(grade)); } if (field != null && !field.isBlank()) { return ApiResp.ok(cslCustomerRepo.findByField(field)); } if (clientType != null && !clientType.isBlank()) { return ApiResp.ok(cslCustomerRepo.findByClientType(clientType)); } if (keyword != null && !keyword.isBlank()) { return ApiResp.ok(cslCustomerRepo.findByNameContaining(keyword)); } return ApiResp.ok(cslCustomerRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(find(id)); } public record CslCustomerRequest( String name, String clientType, String contact, String phone, String region, String field, String grade, String source, String tags, String remark, String lastContactDate) { } /** * 新建 CSL 客户画像档案。满意度/投诉/复购初始化为默认值(无投诉/未评分/无需复购)。 * 若同名客户已存在则返回 409(防止重复建档)。 */ @PostMapping public ApiResp create(@RequestBody CslCustomerRequest req) { if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "客户名称不能为空"); } if (cslCustomerRepo.findByName(req.name().trim()).isPresent()) { throw new ApiException(409, "CSL 客户档案已存在:" + req.name().trim()); } CslCustomer c = new CslCustomer(); fillFrom(c, req); c.setComplaintCount(0); c.setFinishedProjectCount(0); c.setRepurchaseFlag(false); c.setCreatedAt(Instant.now()); c.setUpdatedAt(Instant.now()); return ApiResp.ok(cslCustomerRepo.save(c)); } /** * 更新 CSL 客户画像基础字段(名称/联系人/标签/分级/来源等)。 * 满意度、投诉计数、复购标记、latestCslProjectId 通过专属端点修改,不在此口混更。 */ @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody CslCustomerRequest req) { CslCustomer c = find(id); if (req.name() != null && !req.name().isBlank()) { String newName = req.name().trim(); if (!newName.equals(c.getName())) { cslCustomerRepo.findByName(newName).ifPresent(existing -> { throw new ApiException(409, "已存在同名 CSL 客户档案:" + newName); }); } } fillFrom(c, req); c.setUpdatedAt(Instant.now()); return ApiResp.ok(cslCustomerRepo.save(c)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!cslCustomerRepo.existsById(id)) { throw new NotFoundException("csl customer not found: " + id); } cslCustomerRepo.deleteById(id); return ApiResp.ok(null); } // ===== 满意度评分 ===== public record SatisfactionRequest(Integer score10, String comment) { } /** * 更新满意度评分。score10 为满意度×10(10=1.0, 45=4.5, 50=5.0),支持半档精度。 * 同时更新最近联系日期到今天(录入满意度即代表最近有联系)。 */ @PostMapping("/{id}/satisfaction") @Transactional public ApiResp updateSatisfaction(@PathVariable Long id, @RequestBody SatisfactionRequest req) { CslCustomer c = find(id); if (req.score10() == null || req.score10() < 10 || req.score10() > 50) { throw new ApiException(400, "满意度评分(score10)须在 10-50 之间(满意度1.0-5.0 ×10)"); } c.setSatisfactionScore10(req.score10()); c.setLastContactDate(LocalDate.now().toString()); // 评分≥40(4.0) 且 12个月无新项目时标记复购提醒 updateRepurchaseFlag(c); c.setUpdatedAt(Instant.now()); return ApiResp.ok(cslCustomerRepo.save(c)); } // ===== 投诉计数 ===== public record ComplaintRequest(String complaintSummary) { } /** * 登记一次客诉(complaintCount +1)。complaintSummary 仅记录在返回中, * 完整投诉记录参见 CustomerComplaint 表(/api/oa/customer-complaints)。 */ @PostMapping("/{id}/complaint") @Transactional public ApiResp addComplaint(@PathVariable Long id, @RequestBody(required = false) ComplaintRequest req) { CslCustomer c = find(id); int current = c.getComplaintCount() == null ? 0 : c.getComplaintCount(); c.setComplaintCount(current + 1); // 投诉多(≥3次)时降一档等级 if (current + 1 >= 3 && "A".equals(c.getGrade())) c.setGrade("B"); else if (current + 1 >= 5 && "B".equals(c.getGrade())) c.setGrade("C"); c.setUpdatedAt(Instant.now()); return ApiResp.ok(cslCustomerRepo.save(c)); } // ===== 显式关联最新项目 ===== public record LinkProjectRequest(Long cslProjectId) { } /** * 将某个咨询项目(cslProjectId)显式关联到该 CSL 客户,更新 latestCslProjectId。 * 这取代 CslMarketAnalyticsController 里客户名前4字模糊匹配的低精度方案,实现精确外键关联。 * 同时更新完成项目计数(若项目为"已结项"则 +1)。 */ @PostMapping("/{id}/link-project") @Transactional public ApiResp linkProject(@PathVariable Long id, @RequestBody LinkProjectRequest req) { CslCustomer c = find(id); if (req.cslProjectId() == null) { throw new ApiException(400, "cslProjectId 不能为空"); } CslProject proj = cslProjectRepo.findById(req.cslProjectId()) .orElseThrow(() -> new NotFoundException("csl project not found: " + req.cslProjectId())); c.setLatestCslProjectId(proj.getId()); // 若项目已结项,完成项目计数 +1 if ("已结项".equals(proj.getStage())) { int cnt = c.getFinishedProjectCount() == null ? 0 : c.getFinishedProjectCount(); c.setFinishedProjectCount(cnt + 1); } // 更新最近联系日期 c.setLastContactDate(LocalDate.now().toString()); updateRepurchaseFlag(c); c.setUpdatedAt(Instant.now()); return ApiResp.ok(cslCustomerRepo.save(c)); } // ===== CSL 专项销售漏斗 ===== public record CslFunnelStage(String stage, long count, double conversionRate) { } public record CslFunnelResult( String description, long totalOpportunities, long cslOpportunities, List stages, double overallWinRate, List repurchaseAlerts) { } public record RepurchaseAlert(Long customerId, String name, String grade, String lastContactDate, long monthsSinceContact, double satisfactionScore) { } /** * GET /funnel -> CSL 咨询专项销售漏斗(仅统 projectType=咨询 的商机,与全局漏斗区分)。 * * 漏斗阶段:线索 → 初步接触 → 方案报价 → 商务谈判 → 赢单(各阶段数量+转化率)。 * 附带:复购机会提醒(超6个月未联系且历史满意度≥4.0 的 CSL 客户列表)。 * 这是审计缺口"商机跟踪销售漏斗阶段转化率分析功能弱(无CSL专项漏斗)"的专属端点。 */ @GetMapping("/funnel") public ApiResp funnel() { // 取所有 CSL 咨询类商机(projectType=咨询) List all = opportunityRepo.findAll(); List cslOpps = all.stream() .filter(o -> "咨询".equals(o.getProjectType()) || "可行性研究".equals(o.getProjectType())) .toList(); // 各阶段顺序 List stageOrder = List.of("线索", "初步接触", "方案报价", "商务谈判", "赢单", "输单"); Map countByStage = new LinkedHashMap<>(); for (String s : stageOrder) countByStage.put(s, 0L); for (Opportunity o : cslOpps) { String s = o.getStage() == null ? "线索" : o.getStage(); countByStage.merge(s, 1L, Long::sum); } // 构建漏斗(不含输单阶段——输单是终止态非转化态) List funnelStages = List.of("线索", "初步接触", "方案报价", "商务谈判", "赢单"); long prevCount = cslOpps.size() == 0 ? 1 : cslOpps.size(); List stages = new ArrayList<>(); for (String s : funnelStages) { long cnt = countByStage.getOrDefault(s, 0L); double rate = prevCount == 0 ? 0.0 : Math.round(cnt * 1000.0 / prevCount) / 10.0; if ("线索".equals(s)) rate = 100.0; stages.add(new CslFunnelStage(s, cnt, rate)); if (cnt > 0) prevCount = cnt; } long wonCount = countByStage.getOrDefault("赢单", 0L); double winRate = cslOpps.isEmpty() ? 0.0 : Math.round(wonCount * 1000.0 / cslOpps.size()) / 10.0; // 复购提醒:超6个月未联系 且 满意度≥4.0(score10≥40) String cutoff = LocalDate.now().minusMonths(6).toString(); List repurchaseAlerts = new ArrayList<>(); for (CslCustomer cust : cslCustomerRepo.findAll()) { String lcd = cust.getLastContactDate(); if (lcd == null || lcd.compareTo(cutoff) >= 0) continue; int score10 = cust.getSatisfactionScore10() == null ? 0 : cust.getSatisfactionScore10(); if (score10 < 40) continue; // 满意度<4.0 不列入复购追踪 long months = 0; try { months = ChronoUnit.MONTHS.between(LocalDate.parse(lcd), LocalDate.now()); } catch (Exception ignored) { } repurchaseAlerts.add(new RepurchaseAlert( cust.getId(), cust.getName(), cust.getGrade(), lcd, months, score10 / 10.0)); } repurchaseAlerts.sort((a, b) -> Long.compare(b.monthsSinceContact(), a.monthsSinceContact())); return ApiResp.ok(new CslFunnelResult( "CSL 咨询类专项销售漏斗(仅统 projectType=咨询/可行性研究 的商机)", all.size(), cslOpps.size(), stages, winRate, repurchaseAlerts)); } // ===== 客户画像概览 ===== public record CustomerPortrait( long totalCustomers, long gradeA, long gradeB, long gradeC, long gradeD, double avgSatisfactionScore, long repurchaseCount, long complaintCustomers, String topField, String topClientType) { } /** * GET /portrait -> CSL 客户画像总览统计(各等级分布、平均满意度、复购追踪数、投诉客户数等)。 */ @GetMapping("/portrait") public ApiResp portrait() { List all = cslCustomerRepo.findAll(); long gradeA = all.stream().filter(c -> "A".equals(c.getGrade())).count(); long gradeB = all.stream().filter(c -> "B".equals(c.getGrade())).count(); long gradeC = all.stream().filter(c -> "C".equals(c.getGrade())).count(); long gradeD = all.stream().filter(c -> "D".equals(c.getGrade())).count(); // 平均满意度(只有已评分的客户参与计算) long scored = all.stream().filter(c -> c.getSatisfactionScore10() != null).count(); double avgScore = scored == 0 ? 0.0 : all.stream().filter(c -> c.getSatisfactionScore10() != null) .mapToInt(CslCustomer::getSatisfactionScore10).average().orElse(0.0) / 10.0; long repurchaseCount = all.stream().filter(c -> Boolean.TRUE.equals(c.getRepurchaseFlag())).count(); long complaintCustomers = all.stream().filter(c -> c.getComplaintCount() != null && c.getComplaintCount() > 0).count(); // 最多的行业领域 Map fieldMap = new LinkedHashMap<>(); for (CslCustomer c : all) { String f = c.getField() == null ? "未分类" : c.getField(); fieldMap.merge(f, 1L, Long::sum); } String topField = fieldMap.entrySet().stream().max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey).orElse("-"); Map typeMap = new LinkedHashMap<>(); for (CslCustomer c : all) { String t = c.getClientType() == null ? "未分类" : c.getClientType(); typeMap.merge(t, 1L, Long::sum); } String topType = typeMap.entrySet().stream().max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey).orElse("-"); return ApiResp.ok(new CustomerPortrait(all.size(), gradeA, gradeB, gradeC, gradeD, Math.round(avgScore * 10.0) / 10.0, repurchaseCount, complaintCustomers, topField, topType)); } // ===== helpers ===== private CslCustomer find(Long id) { return cslCustomerRepo.findById(id) .orElseThrow(() -> new NotFoundException("csl customer not found: " + id)); } private void fillFrom(CslCustomer c, CslCustomerRequest req) { if (req.name() != null && !req.name().isBlank()) c.setName(req.name().trim()); if (req.clientType() != null) c.setClientType(req.clientType()); if (req.contact() != null) c.setContact(req.contact()); if (req.phone() != null) c.setPhone(req.phone()); if (req.region() != null) c.setRegion(req.region()); if (req.field() != null) c.setField(req.field()); if (req.grade() != null) c.setGrade(req.grade()); if (req.source() != null) c.setSource(req.source()); if (req.tags() != null) c.setTags(req.tags()); if (req.remark() != null) c.setRemark(req.remark()); if (req.lastContactDate() != null) c.setLastContactDate(req.lastContactDate()); } /** * 根据满意度与最近联系日期自动推导复购标记: * 满意度≥4.0(score10≥40)且超过 12个月未联系 → 标记需复购跟进。 */ private static void updateRepurchaseFlag(CslCustomer c) { String lcd = c.getLastContactDate(); if (lcd == null) { c.setRepurchaseFlag(false); return; } int score10 = c.getSatisfactionScore10() == null ? 0 : c.getSatisfactionScore10(); try { LocalDate lastContact = LocalDate.parse(lcd); long months = ChronoUnit.MONTHS.between(lastContact, LocalDate.now()); c.setRepurchaseFlag(score10 >= 40 && months >= 12); } catch (Exception e) { c.setRepurchaseFlag(false); } } }