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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,400 @@
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<CslCustomer>> 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<CslCustomer> 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<CslCustomer> 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<CslCustomer> 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<Void> 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 为满意度×1010=1.0, 45=4.5, 50=5.0),支持半档精度。
* 同时更新最近联系日期到今天(录入满意度即代表最近有联系)。
*/
@PostMapping("/{id}/satisfaction")
@Transactional
public ApiResp<CslCustomer> 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<CslCustomer> 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<CslCustomer> 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<CslFunnelStage> stages,
double overallWinRate,
List<RepurchaseAlert> 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<CslFunnelResult> funnel() {
// 取所有 CSL 咨询类商机(projectType=咨询)
List<Opportunity> all = opportunityRepo.findAll();
List<Opportunity> cslOpps = all.stream()
.filter(o -> "咨询".equals(o.getProjectType()) || "可行性研究".equals(o.getProjectType()))
.toList();
// 各阶段顺序
List<String> stageOrder = List.of("线索", "初步接触", "方案报价", "商务谈判", "赢单", "输单");
Map<String, Long> 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<String> funnelStages = List.of("线索", "初步接触", "方案报价", "商务谈判", "赢单");
long prevCount = cslOpps.size() == 0 ? 1 : cslOpps.size();
List<CslFunnelStage> 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<RepurchaseAlert> 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<CustomerPortrait> portrait() {
List<CslCustomer> 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<String, Long> 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<String, Long> 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.0score10≥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);
}
}
}