Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/CslCustomerController.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

401 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.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);
}
}
}