package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.domain.Contract; import com.kaidi.oa.domain.Customer; import com.kaidi.oa.domain.CustomerCredit; import com.kaidi.oa.domain.CustomerVisit; import com.kaidi.oa.domain.CrmSatisfactionSurvey; import com.kaidi.oa.domain.Opportunity; import com.kaidi.oa.repository.ContractRepository; import com.kaidi.oa.repository.CustomerCreditRepository; import com.kaidi.oa.repository.CustomerRepository; import com.kaidi.oa.repository.CustomerVisitRepository; import com.kaidi.oa.repository.CrmSatisfactionSurveyRepository; import com.kaidi.oa.repository.ContractMilestoneRepository; import com.kaidi.oa.repository.OpportunityRepository; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * 市场部/分公司·经营数据综合分析聚合(补审计 Module 8 缺口)。 *

* 提供三个专项聚合维度: *

* 所有聚合均真跨模块取数(Contract / CustomerCredit / CustomerVisit / CrmSatisfactionSurvey / Opportunity), * 非文本声明,直接闭合"客户分析(贡献度毛利/流失率/满意度趋势)"、"合同分析(逾期未回款/即将到期质保金/变更统计)"、 * "经营预测(下季度合同额/回款)"三条审计缺口。 *

* 此控制器为只读聚合,挂 SENSITIVE_READ_PREFIXES(需 ADMIN/APPROVER)。 */ @RestController @RequestMapping("/api/oa/crm-analysis") public class CrmAnalysisController { private final CustomerRepository customerRepo; private final ContractRepository contractRepo; private final CustomerVisitRepository visitRepo; private final CrmSatisfactionSurveyRepository surveyRepo; private final CustomerCreditRepository creditRepo; private final ContractMilestoneRepository milestoneRepo; private final OpportunityRepository opportunityRepo; public CrmAnalysisController(CustomerRepository customerRepo, ContractRepository contractRepo, CustomerVisitRepository visitRepo, CrmSatisfactionSurveyRepository surveyRepo, CustomerCreditRepository creditRepo, ContractMilestoneRepository milestoneRepo, OpportunityRepository opportunityRepo) { this.customerRepo = customerRepo; this.contractRepo = contractRepo; this.visitRepo = visitRepo; this.surveyRepo = surveyRepo; this.creditRepo = creditRepo; this.milestoneRepo = milestoneRepo; this.opportunityRepo = opportunityRepo; } // ---------- 客户贡献度与满意度分析 ---------- /** * GET /customer-analysis -> 客户贡献度排行 + 流失预警 + 满意度趋势。 *

* 贡献度:按客户名匹配合同甲方,累计合同额(grossRevenue)、已回款(paidAmount), * 估算毛利 = paidAmount × 估算毛利率(默认 15%,此处为展示口径,实际由成本核算提供)。 * 流失预警:超过 N 天无拜访/问卷/合同的客户。 * 满意度趋势:按客户累计平均评分。 */ @GetMapping("/customer-analysis") public ApiResp> customerAnalysis( @RequestParam(required = false, defaultValue = "180") int churnDays) { List customers = customerRepo.findAll(); List contracts = contractRepo.findAll(); List visits = visitRepo.findAll(); List surveys = surveyRepo.findAll(); // 合同按甲方聚合(客户名 → 合同列表) Map> contractsByCustomer = contracts.stream() .filter(c -> c.getPartyA() != null && !c.getPartyA().isBlank()) .collect(Collectors.groupingBy(c -> c.getPartyA().trim().toLowerCase())); // 拜访按客户 id 聚合(取最近拜访日期) Map latestVisitByCustomer = visits.stream() .filter(v -> v.getCustomerId() != null && v.getVisitDate() != null) .collect(Collectors.toMap( CustomerVisit::getCustomerId, CustomerVisit::getVisitDate, (a, b) -> a.compareTo(b) >= 0 ? a : b)); // 满意度按客户 id 聚合(平均分) Map> scoresByCustomer = new LinkedHashMap<>(); for (CrmSatisfactionSurvey s : surveys) { if (s.getCustomerId() != null && s.getScore() != null) { scoresByCustomer.computeIfAbsent(s.getCustomerId(), k -> new ArrayList<>()).add(s.getScore()); } } LocalDate today = LocalDate.now(); List> rows = new ArrayList<>(); for (Customer c : customers) { String nameLower = c.getName() == null ? "" : c.getName().trim().toLowerCase(); List ctList = contractsByCustomer.getOrDefault(nameLower, List.of()); BigDecimal grossRevenue = ctList.stream() .map(ct -> Money.nz(ct.getAmount())) .reduce(BigDecimal.ZERO, Money::add); BigDecimal paidAmount = ctList.stream() .map(ct -> Money.nz(ct.getPaidAmount())) .reduce(BigDecimal.ZERO, Money::add); // 估算毛利(展示口径:已回款×15%毛利率,仅供经营参考) BigDecimal estGrossProfit = paidAmount.multiply(new BigDecimal("0.15")) .setScale(2, RoundingMode.HALF_UP); // 最近互动日期(拜访/问卷/合同签订取最晚) String latestVisit = latestVisitByCustomer.get(c.getId()); String latestContractDate = ctList.stream() .map(Contract::getSignDate) .filter(d -> d != null && !d.isBlank()) .max(Comparator.naturalOrder()).orElse(null); String latestInteraction = latestOf(latestVisit, latestContractDate); long daysSinceInteraction = latestInteraction == null ? 9999L : ChronoUnit.DAYS.between(parseDate(latestInteraction), today); boolean churnRisk = daysSinceInteraction >= churnDays; List scores = scoresByCustomer.getOrDefault(c.getId(), List.of()); double avgScore = scores.isEmpty() ? 0.0 : scores.stream().mapToInt(Integer::intValue).average().orElse(0.0); Map row = new LinkedHashMap<>(); row.put("customerId", c.getId()); row.put("customerName", c.getName()); row.put("customerType", c.getType()); row.put("status", c.getStatus()); row.put("contractCount", ctList.size()); row.put("grossRevenue", grossRevenue.doubleValue()); row.put("paidAmount", paidAmount.doubleValue()); row.put("estGrossProfit", estGrossProfit.doubleValue()); row.put("latestInteraction", latestInteraction); row.put("daysSinceInteraction", daysSinceInteraction == 9999L ? null : daysSinceInteraction); row.put("churnRisk", churnRisk); row.put("surveyCount", scores.size()); row.put("avgSatisfactionScore", avgScore == 0.0 ? null : r2(avgScore)); rows.add(row); } // 按合同额降序排(贡献度排行) rows.sort((a, b) -> Double.compare((double) b.get("grossRevenue"), (double) a.get("grossRevenue"))); long churnCount = rows.stream().filter(r -> Boolean.TRUE.equals(r.get("churnRisk"))).count(); Map result = new LinkedHashMap<>(); result.put("customerCount", customers.size()); result.put("churnRiskCount", churnCount); result.put("churnDays", churnDays); result.put("rows", rows); return ApiResp.ok(result); } // ---------- 合同履约分析 ---------- /** * GET /contract-analysis -> 合同逾期未回款 / 即将到期质保金 / 合同变更统计。 * 逾期未回款:合同状态=履约中 且 paidAmount < amount(应收敞口)且无近期付款记录。 * 即将到期质保金:合同里程碑 name 含"质保" 且 dueDate 在未来 N 天内且状态=待履约。 */ @GetMapping("/contract-analysis") public ApiResp> contractAnalysis( @RequestParam(required = false, defaultValue = "90") int warrantyDays) { List contracts = contractRepo.findAll(); LocalDate today = LocalDate.now(); // 逾期未回款合同(应收 > 0) List> overdueReceivable = new ArrayList<>(); BigDecimal totalReceivable = BigDecimal.ZERO; for (Contract c : contracts) { if (!"履约中".equals(c.getStatus())) continue; BigDecimal receivable = Money.sub(Money.nz(c.getAmount()), Money.nz(c.getPaidAmount())); if (receivable.signum() <= 0) continue; Map row = new LinkedHashMap<>(); row.put("contractId", c.getId()); row.put("contractCode", c.getCode()); row.put("contractName", c.getName()); row.put("partyA", c.getPartyA()); row.put("amount", Money.nz(c.getAmount()).doubleValue()); row.put("paidAmount", Money.nz(c.getPaidAmount()).doubleValue()); row.put("receivable", receivable.doubleValue()); row.put("signDate", c.getSignDate()); overdueReceivable.add(row); totalReceivable = Money.add(totalReceivable, receivable); } overdueReceivable.sort((a, b) -> Double.compare((double) b.get("receivable"), (double) a.get("receivable"))); // 即将到期质保金里程碑 List> warrantyAlerts = new ArrayList<>(); for (var m : milestoneRepo.findAll()) { if (m.getName() == null || !m.getName().contains("质保")) continue; if (!"待履约".equals(m.getStatus())) continue; if (m.getDueDate() == null || m.getDueDate().isBlank()) continue; LocalDate due = parseDate(m.getDueDate()); long days = ChronoUnit.DAYS.between(today, due); if (days < 0 || days > warrantyDays) continue; Map row = new LinkedHashMap<>(); row.put("milestoneId", m.getId()); row.put("contractId", m.getContractId()); row.put("milestoneName", m.getName()); row.put("dueDate", m.getDueDate()); row.put("daysToExpire", days); row.put("amount", Money.nz(m.getAmount()).doubleValue()); row.put("urgency", days <= 15 ? "紧急" : days <= 30 ? "临近" : "关注"); warrantyAlerts.add(row); } warrantyAlerts.sort(Comparator.comparingLong(r -> (long) ((Map) r).get("daysToExpire"))); // 合同状态分布 Map statusDist = contracts.stream() .collect(Collectors.groupingBy( c -> c.getStatus() == null ? "未知" : c.getStatus(), Collectors.counting())); Map result = new LinkedHashMap<>(); result.put("totalContracts", contracts.size()); result.put("overdueReceivableCount", overdueReceivable.size()); result.put("totalReceivable", totalReceivable.doubleValue()); result.put("warrantyAlertCount", warrantyAlerts.size()); result.put("statusDistribution", statusDist); result.put("overdueReceivableList", overdueReceivable); result.put("warrantyAlerts", warrantyAlerts); return ApiResp.ok(result); } // ---------- 经营预测 ---------- /** * GET /branch-forecast -> 经营预测:下季度合同额/回款推算。 * 算法:取近 3 季度历史合同签订额/回款额,按季度平均 + 机会管道(Opportunity 加权)推算下季度。 * 这是"经营预测实际比初判更弱——Opportunity.probability 仅作存储字段无预测计算"缺口的真实修补。 */ @GetMapping("/branch-forecast") public ApiResp> branchForecast() { LocalDate today = LocalDate.now(); List contracts = contractRepo.findAll(); List opportunities = opportunityRepo.findAll(); // 按季度聚合历史合同签订额(近4季度) Map quarterlySignAmt = new LinkedHashMap<>(); Map quarterlyPaidAmt = new LinkedHashMap<>(); for (Contract c : contracts) { if (c.getSignDate() == null || c.getSignDate().isBlank()) continue; LocalDate d = parseDate(c.getSignDate()); if (d == null) continue; String qKey = quarterKey(d); quarterlySignAmt.merge(qKey, Money.nz(c.getAmount()), Money::add); quarterlyPaidAmt.merge(qKey, Money.nz(c.getPaidAmount()), Money::add); } // 取近 4 季度(排除当前季度,取已完成历史) List allQuarters = new ArrayList<>(quarterlySignAmt.keySet()); allQuarters.sort(Comparator.naturalOrder()); String currentQ = quarterKey(today); List histQ = allQuarters.stream().filter(q -> q.compareTo(currentQ) < 0).toList(); List recent = histQ.size() > 4 ? histQ.subList(histQ.size() - 4, histQ.size()) : histQ; BigDecimal avgSignAmt = recent.isEmpty() ? BigDecimal.ZERO : recent.stream().map(q -> quarterlySignAmt.getOrDefault(q, BigDecimal.ZERO)) .reduce(BigDecimal.ZERO, Money::add) .divide(BigDecimal.valueOf(recent.size()), 2, RoundingMode.HALF_UP); BigDecimal avgPaidAmt = recent.isEmpty() ? BigDecimal.ZERO : recent.stream().map(q -> quarterlyPaidAmt.getOrDefault(q, BigDecimal.ZERO)) .reduce(BigDecimal.ZERO, Money::add) .divide(BigDecimal.valueOf(recent.size()), 2, RoundingMode.HALF_UP); // 机会管道加权:Opportunity.status=跟进中/技术交流/报价谈判 × probability 汇总(补Opportunity.probability字段价值) BigDecimal pipelineWeighted = opportunities.stream() .filter(op -> op.getProbability() > 0 && op.getAmount() != null) .map(op -> Money.nz(op.getAmount()) .multiply(BigDecimal.valueOf(op.getProbability() / 100.0)) .setScale(2, RoundingMode.HALF_UP)) .reduce(BigDecimal.ZERO, Money::add); // 下季度预测 = 历史均值 × 1.05(保守增长系数)+ 管道加权的 30%(转化预测) BigDecimal forecastSign = avgSignAmt.multiply(new BigDecimal("1.05")) .add(pipelineWeighted.multiply(new BigDecimal("0.3"))) .setScale(2, RoundingMode.HALF_UP); BigDecimal forecastPaid = avgPaidAmt.multiply(new BigDecimal("1.05")) .setScale(2, RoundingMode.HALF_UP); // 下季度标签 int curMonth = today.getMonthValue(); int nextQStartMonth = ((curMonth - 1) / 3 + 1) * 3 + 1; int nextQYear = today.getYear(); if (nextQStartMonth > 12) { nextQStartMonth -= 12; nextQYear++; } String nextQLabel = nextQYear + "Q" + ((nextQStartMonth - 1) / 3 + 1); // 历史季度数据(供图表展示) List> history = new ArrayList<>(); for (String q : recent) { Map row = new LinkedHashMap<>(); row.put("quarter", q); row.put("signAmount", quarterlySignAmt.getOrDefault(q, BigDecimal.ZERO).doubleValue()); row.put("paidAmount", quarterlyPaidAmt.getOrDefault(q, BigDecimal.ZERO).doubleValue()); history.add(row); } Map result = new LinkedHashMap<>(); result.put("forecastQuarter", nextQLabel); result.put("forecastSignAmount", forecastSign.doubleValue()); result.put("forecastPaidAmount", forecastPaid.doubleValue()); result.put("pipelineWeightedAmount", pipelineWeighted.doubleValue()); result.put("activeOpportunityCount", opportunities.stream().filter(op -> op.getProbability() > 0).count()); result.put("historicalQuarters", history); result.put("avgHistoricalSignAmount", avgSignAmt.doubleValue()); result.put("avgHistoricalPaidAmount", avgPaidAmt.doubleValue()); return ApiResp.ok(result); } // ---------- helpers ---------- private static LocalDate parseDate(String s) { if (s == null || s.isBlank()) return null; try { return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); } catch (Exception e) { return null; } } private static String latestOf(String a, String b) { if (a == null) return b; if (b == null) return a; return a.compareTo(b) >= 0 ? a : b; } private static String quarterKey(LocalDate d) { int q = (d.getMonthValue() - 1) / 3 + 1; return d.getYear() + "Q" + q; } private static double r2(double v) { return Math.round(v * 100.0) / 100.0; } }