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>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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 < 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user