Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/CrmAnalysisController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

359 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 缺口)。
* <p>
* 提供三个专项聚合维度:
* <ul>
* <li>GET /customer-analysis : 客户贡献度毛利 / 流失率 / 满意度趋势(真跨模块)。</li>
* <li>GET /contract-analysis : 逾期未回款 / 即将到期质保金 / 变更统计(跨 contract/milestone/change)。</li>
* <li>GET /branch-forecast : 经营预测——下季度合同额/回款(按历史趋势推算,替换原空字段 Opportunity.probability)。</li>
* </ul>
* 所有聚合均真跨模块取数(Contract / CustomerCredit / CustomerVisit / CrmSatisfactionSurvey / Opportunity),
* 非文本声明,直接闭合"客户分析(贡献度毛利/流失率/满意度趋势)"、"合同分析(逾期未回款/即将到期质保金/变更统计)"、
* "经营预测(下季度合同额/回款)"三条审计缺口。
* <p>
* 此控制器为只读聚合,挂 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 -> 客户贡献度排行 + 流失预警 + 满意度趋势。
* <p>
* 贡献度:按客户名匹配合同甲方,累计合同额(grossRevenue)、已回款(paidAmount),
* 估算毛利 = paidAmount × 估算毛利率(默认 15%,此处为展示口径,实际由成本核算提供)。
* 流失预警:超过 N 天无拜访/问卷/合同的客户。
* 满意度趋势:按客户累计平均评分。
*/
@GetMapping("/customer-analysis")
public ApiResp<Map<String, Object>> customerAnalysis(
@RequestParam(required = false, defaultValue = "180") int churnDays) {
List<Customer> customers = customerRepo.findAll();
List<Contract> contracts = contractRepo.findAll();
List<CustomerVisit> visits = visitRepo.findAll();
List<CrmSatisfactionSurvey> surveys = surveyRepo.findAll();
// 合同按甲方聚合(客户名 → 合同列表)
Map<String, List<Contract>> contractsByCustomer = contracts.stream()
.filter(c -> c.getPartyA() != null && !c.getPartyA().isBlank())
.collect(Collectors.groupingBy(c -> c.getPartyA().trim().toLowerCase()));
// 拜访按客户 id 聚合(取最近拜访日期)
Map<Long, String> 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<Long, List<Integer>> 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<Map<String, Object>> rows = new ArrayList<>();
for (Customer c : customers) {
String nameLower = c.getName() == null ? "" : c.getName().trim().toLowerCase();
List<Contract> 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<Integer> scores = scoresByCustomer.getOrDefault(c.getId(), List.of());
double avgScore = scores.isEmpty() ? 0.0
: scores.stream().mapToInt(Integer::intValue).average().orElse(0.0);
Map<String, Object> 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<String, Object> 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 &lt; amount(应收敞口)且无近期付款记录。
* 即将到期质保金:合同里程碑 name 含"质保" 且 dueDate 在未来 N 天内且状态=待履约。
*/
@GetMapping("/contract-analysis")
public ApiResp<Map<String, Object>> contractAnalysis(
@RequestParam(required = false, defaultValue = "90") int warrantyDays) {
List<Contract> contracts = contractRepo.findAll();
LocalDate today = LocalDate.now();
// 逾期未回款合同(应收 > 0)
List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object>) r).get("daysToExpire")));
// 合同状态分布
Map<String, Long> statusDist = contracts.stream()
.collect(Collectors.groupingBy(
c -> c.getStatus() == null ? "未知" : c.getStatus(),
Collectors.counting()));
Map<String, Object> 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<Map<String, Object>> branchForecast() {
LocalDate today = LocalDate.now();
List<Contract> contracts = contractRepo.findAll();
List<Opportunity> opportunities = opportunityRepo.findAll();
// 按季度聚合历史合同签订额(近4季度)
Map<String, BigDecimal> quarterlySignAmt = new LinkedHashMap<>();
Map<String, BigDecimal> 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<String> allQuarters = new ArrayList<>(quarterlySignAmt.keySet());
allQuarters.sort(Comparator.naturalOrder());
String currentQ = quarterKey(today);
List<String> histQ = allQuarters.stream().filter(q -> q.compareTo(currentQ) < 0).toList();
List<String> 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<Map<String, Object>> history = new ArrayList<>();
for (String q : recent) {
Map<String, Object> 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<String, Object> 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;
}
}