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,484 @@
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.BidKnowledgeEntry;
import com.kaidi.oa.domain.CslBidLink;
import com.kaidi.oa.domain.CslProject;
import com.kaidi.oa.domain.Bid;
import com.kaidi.oa.domain.Opportunity;
import com.kaidi.oa.repository.BidKnowledgeEntryRepository;
import com.kaidi.oa.repository.BidRepository;
import com.kaidi.oa.repository.CslBidLinkRepository;
import com.kaidi.oa.repository.CslProjectRepository;
import com.kaidi.oa.repository.OpportunityRepository;
import org.springframework.scheduling.annotation.Scheduled;
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;
/**
* 投标知识库(咨询可研院·招投标管理·知识库·审计缺口闭合)。
*
* 本控制器闭合三条审计缺口:
* ① 投标知识库实体缺失:建立 BidKnowledgeEntry(标书范本/报价策略/评审记录/竞情分析),
* 支持全文关键词检索、按类型/阶段/领域筛选、质量评分统计、热门条目排行。
* ② CSL项目与Bid/Opportunity无显式外键:通过 CslBidLink 显式关联表(确认关联),
* 取代 CslMarketAnalyticsController 的客户名前4字模糊匹配方案(准确率低)。
* ③ 复购机会系统内自动提醒:@Scheduled 每日扫描超过6个月无新项目的老客户,
* 写入系统内提醒标志(repurchaseAlerts 聚合端点输出),作为应用内提醒兜底。
* 注:外部推送(JPush/APNs)属外部系统,本轮跳过。
*/
@RestController
@RequestMapping("/api/oa/bid-knowledge")
public class BidKnowledgeController {
private final BidKnowledgeEntryRepository knowledgeRepo;
private final CslBidLinkRepository cslBidLinkRepo;
private final CslProjectRepository cslProjectRepo;
private final BidRepository bidRepo;
private final OpportunityRepository opportunityRepo;
public BidKnowledgeController(BidKnowledgeEntryRepository knowledgeRepo,
CslBidLinkRepository cslBidLinkRepo,
CslProjectRepository cslProjectRepo,
BidRepository bidRepo,
OpportunityRepository opportunityRepo) {
this.knowledgeRepo = knowledgeRepo;
this.cslBidLinkRepo = cslBidLinkRepo;
this.cslProjectRepo = cslProjectRepo;
this.bidRepo = bidRepo;
this.opportunityRepo = opportunityRepo;
}
// ===== ① 投标知识库 CRUD =====
public record CreateKnowledgeRequest(
String title, String entryType, String phase, String projectType,
String field, String region, String tags, String quoteStrategy,
String reviewNotes, String bidFileRef, String content,
Long bidId, Long opportunityId, Long cslProjectId,
Integer qualityScore, String creator, Boolean isPublic) {
}
@PostMapping
@Transactional
public ApiResp<BidKnowledgeEntry> create(@RequestBody CreateKnowledgeRequest req) {
if (req.title() == null || req.title().isBlank()) {
throw new ApiException(400, "知识条目标题不能为空");
}
if (req.entryType() == null || req.entryType().isBlank()) {
throw new ApiException(400, "条目类型不能为空(标书范本/报价策略/评审记录/竞情分析/合规要点/其他)");
}
BidKnowledgeEntry entry = new BidKnowledgeEntry();
entry.setTitle(req.title());
entry.setEntryType(req.entryType());
entry.setPhase(req.phase());
entry.setProjectType(req.projectType());
entry.setField(req.field());
entry.setRegion(req.region());
entry.setTags(req.tags());
entry.setQuoteStrategy(req.quoteStrategy());
entry.setReviewNotes(req.reviewNotes());
entry.setBidFileRef(req.bidFileRef());
entry.setContent(req.content());
entry.setBidId(req.bidId());
entry.setOpportunityId(req.opportunityId());
entry.setCslProjectId(req.cslProjectId());
entry.setQualityScore(req.qualityScore());
entry.setCreator(req.creator());
entry.setIsPublic(req.isPublic() == null ? Boolean.TRUE : req.isPublic());
entry.setViewCount(0);
entry.setCreatedAt(Instant.now());
entry.setUpdatedAt(Instant.now());
return ApiResp.ok(knowledgeRepo.save(entry));
}
@GetMapping
public ApiResp<List<BidKnowledgeEntry>> list(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String entryType,
@RequestParam(required = false) String projectType,
@RequestParam(required = false) String phase,
@RequestParam(required = false) String field,
@RequestParam(required = false) String tag,
@RequestParam(required = false) Boolean isPublic) {
List<BidKnowledgeEntry> all;
if (keyword != null && !keyword.isBlank()) {
all = knowledgeRepo.findByTitleContaining(keyword);
} else if (entryType != null && !entryType.isBlank()) {
all = knowledgeRepo.findByEntryType(entryType);
} else if (projectType != null && !projectType.isBlank()) {
all = knowledgeRepo.findByProjectType(projectType);
} else if (phase != null && !phase.isBlank()) {
all = knowledgeRepo.findByPhase(phase);
} else if (field != null && !field.isBlank()) {
all = knowledgeRepo.findByField(field);
} else if (tag != null && !tag.isBlank()) {
all = knowledgeRepo.findByTagsContaining(tag);
} else if (isPublic != null) {
all = knowledgeRepo.findByIsPublic(isPublic);
} else {
all = knowledgeRepo.findAll();
}
return ApiResp.ok(all);
}
@GetMapping("/{id}")
@Transactional
public ApiResp<BidKnowledgeEntry> get(@PathVariable Long id) {
BidKnowledgeEntry entry = knowledgeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("知识条目不存在: " + id));
// 记录浏览次数
entry.setViewCount(entry.getViewCount() == null ? 1 : entry.getViewCount() + 1);
return ApiResp.ok(knowledgeRepo.save(entry));
}
public record UpdateKnowledgeRequest(
String title, String entryType, String phase, String projectType,
String field, String region, String tags, String quoteStrategy,
String reviewNotes, String bidFileRef, String content,
Long bidId, Long opportunityId, Long cslProjectId,
Integer qualityScore, Boolean isPublic) {
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<BidKnowledgeEntry> update(@PathVariable Long id,
@RequestBody UpdateKnowledgeRequest req) {
BidKnowledgeEntry entry = knowledgeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("知识条目不存在: " + id));
if (req.title() != null) entry.setTitle(req.title());
if (req.entryType() != null) entry.setEntryType(req.entryType());
if (req.phase() != null) entry.setPhase(req.phase());
if (req.projectType() != null) entry.setProjectType(req.projectType());
if (req.field() != null) entry.setField(req.field());
if (req.region() != null) entry.setRegion(req.region());
if (req.tags() != null) entry.setTags(req.tags());
if (req.quoteStrategy() != null) entry.setQuoteStrategy(req.quoteStrategy());
if (req.reviewNotes() != null) entry.setReviewNotes(req.reviewNotes());
if (req.bidFileRef() != null) entry.setBidFileRef(req.bidFileRef());
if (req.content() != null) entry.setContent(req.content());
if (req.bidId() != null) entry.setBidId(req.bidId());
if (req.opportunityId() != null) entry.setOpportunityId(req.opportunityId());
if (req.cslProjectId() != null) entry.setCslProjectId(req.cslProjectId());
if (req.qualityScore() != null) entry.setQualityScore(req.qualityScore());
if (req.isPublic() != null) entry.setIsPublic(req.isPublic());
entry.setUpdatedAt(Instant.now());
return ApiResp.ok(knowledgeRepo.save(entry));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!knowledgeRepo.existsById(id)) {
throw new NotFoundException("知识条目不存在: " + id);
}
knowledgeRepo.deleteById(id);
return ApiResp.ok(null);
}
// ===== 知识库统计分析 =====
/**
* GET /stats -> 按类型分类统计:条目数 + 平均质量评分 + 被引用次数(浏览量合计)。
*/
@GetMapping("/stats")
public ApiResp<List<Map<String, Object>>> stats() {
List<BidKnowledgeEntry> all = knowledgeRepo.findAll();
Map<String, long[]> byType = new LinkedHashMap<>();
Map<String, long[]> scoreMap = new LinkedHashMap<>();
Map<String, long[]> viewMap = new LinkedHashMap<>();
for (BidKnowledgeEntry e : all) {
String t = e.getEntryType() == null ? "其他" : e.getEntryType();
byType.computeIfAbsent(t, k -> new long[1])[0]++;
if (e.getQualityScore() != null) {
scoreMap.computeIfAbsent(t, k -> new long[2]);
scoreMap.get(t)[0] += e.getQualityScore();
scoreMap.get(t)[1]++;
}
if (e.getViewCount() != null) {
viewMap.computeIfAbsent(t, k -> new long[1])[0] += e.getViewCount();
}
}
List<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, long[]> en : byType.entrySet()) {
String t = en.getKey();
long cnt = en.getValue()[0];
long[] sc = scoreMap.getOrDefault(t, new long[2]);
double avgScore = sc[1] == 0 ? 0.0 : Math.round(sc[0] * 10.0 / sc[1]) / 10.0;
long totalViews = viewMap.getOrDefault(t, new long[1])[0];
Map<String, Object> row = new LinkedHashMap<>();
row.put("entryType", t);
row.put("count", cnt);
row.put("avgQualityScore", avgScore);
row.put("totalViews", totalViews);
result.add(row);
}
result.sort((a, b) -> Long.compare((long) b.get("count"), (long) a.get("count")));
return ApiResp.ok(result);
}
/**
* GET /top-viewed -> 浏览量 Top 10 热门知识条目(报价策略/标书范本的热度排行)。
*/
@GetMapping("/top-viewed")
public ApiResp<List<BidKnowledgeEntry>> topViewed() {
List<BidKnowledgeEntry> all = new ArrayList<>(knowledgeRepo.findAll());
all.sort((a, b) -> {
int va = a.getViewCount() == null ? 0 : a.getViewCount();
int vb = b.getViewCount() == null ? 0 : b.getViewCount();
return Integer.compare(vb, va);
});
return ApiResp.ok(all.subList(0, Math.min(10, all.size())));
}
// ===== ② CSL项目与Bid/Opportunity显式关联 =====
public record CreateLinkRequest(
Long cslProjectId, Long bidId, Long opportunityId,
String linkType, String remark, String confirmedBy) {
}
@PostMapping("/csl-bid-links")
@Transactional
public ApiResp<CslBidLink> createLink(@RequestBody CreateLinkRequest req) {
if (req.cslProjectId() == null) {
throw new ApiException(400, "cslProjectId 不能为空");
}
if (req.bidId() == null && req.opportunityId() == null) {
throw new ApiException(400, "bidId 或 opportunityId 至少传一个");
}
if (!cslProjectRepo.existsById(req.cslProjectId())) {
throw new NotFoundException("咨询项目不存在: " + req.cslProjectId());
}
// 防重:同一 cslProject+bid 已关联
if (req.bidId() != null && cslBidLinkRepo.existsByCslProjectIdAndBidId(req.cslProjectId(), req.bidId())) {
throw new ApiException(409, "该咨询项目已关联此投标,请勿重复创建");
}
if (req.opportunityId() != null && cslBidLinkRepo.existsByCslProjectIdAndOpportunityId(req.cslProjectId(), req.opportunityId())) {
throw new ApiException(409, "该咨询项目已关联此商机,请勿重复创建");
}
CslBidLink link = new CslBidLink();
link.setCslProjectId(req.cslProjectId());
link.setBidId(req.bidId());
link.setOpportunityId(req.opportunityId());
link.setLinkType(req.linkType() == null ? (req.bidId() != null && req.opportunityId() != null ? "两者均关联" : req.bidId() != null ? "投标关联" : "商机关联") : req.linkType());
link.setRemark(req.remark());
link.setConfirmedBy(req.confirmedBy());
link.setCreatedAt(Instant.now());
return ApiResp.ok(cslBidLinkRepo.save(link));
}
@GetMapping("/csl-bid-links")
public ApiResp<List<Map<String, Object>>> listLinks(
@RequestParam(required = false) Long cslProjectId,
@RequestParam(required = false) Long bidId) {
List<CslBidLink> links;
if (cslProjectId != null) {
links = cslBidLinkRepo.findByCslProjectId(cslProjectId);
} else if (bidId != null) {
links = cslBidLinkRepo.findByBidId(bidId);
} else {
links = cslBidLinkRepo.findAll();
}
List<Map<String, Object>> result = new ArrayList<>();
for (CslBidLink lk : links) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", lk.getId());
row.put("cslProjectId", lk.getCslProjectId());
row.put("bidId", lk.getBidId());
row.put("opportunityId", lk.getOpportunityId());
row.put("linkType", lk.getLinkType());
row.put("remark", lk.getRemark());
row.put("confirmedBy", lk.getConfirmedBy());
row.put("createdAt", lk.getCreatedAt());
// 富化 CSL 项目名称
if (lk.getCslProjectId() != null) {
cslProjectRepo.findById(lk.getCslProjectId()).ifPresent(p -> {
row.put("cslProjectName", p.getName());
row.put("cslProjectStage", p.getStage());
row.put("cslClient", p.getClient());
});
}
// 富化投标名称
if (lk.getBidId() != null) {
bidRepo.findById(lk.getBidId()).ifPresent(b -> {
row.put("bidProjectName", b.getProjectName());
row.put("bidStatus", b.getStatus());
row.put("tenderee", b.getTenderee());
});
}
// 富化商机名称
if (lk.getOpportunityId() != null) {
opportunityRepo.findById(lk.getOpportunityId()).ifPresent(o -> {
row.put("opportunityName", o.getName());
row.put("opportunityStage", o.getStage());
});
}
result.add(row);
}
return ApiResp.ok(result);
}
@DeleteMapping("/csl-bid-links/{id}")
public ApiResp<Void> deleteLink(@PathVariable Long id) {
if (!cslBidLinkRepo.existsById(id)) {
throw new NotFoundException("关联记录不存在: " + id);
}
cslBidLinkRepo.deleteById(id);
return ApiResp.ok(null);
}
/**
* GET /csl-bid-links/accurate-view -> 基于显式关联的精确视图(替代模糊匹配)。
* 聚合 CslBidLink 的精确外键关联,输出 CSL项目-投标-商机 三者全链条视图(准确率100%)。
*/
@GetMapping("/csl-bid-links/accurate-view")
public ApiResp<List<Map<String, Object>>> accurateLinkView(
@RequestParam(required = false) String client) {
List<CslBidLink> all = cslBidLinkRepo.findAll();
List<Map<String, Object>> result = new ArrayList<>();
for (CslBidLink lk : all) {
CslProject proj = lk.getCslProjectId() == null ? null
: cslProjectRepo.findById(lk.getCslProjectId()).orElse(null);
if (proj == null) continue;
// 客户筛选
if (client != null && !client.isBlank()) {
String c = proj.getClient() == null ? "" : proj.getClient();
if (!c.contains(client)) continue;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("linkId", lk.getId());
row.put("linkType", lk.getLinkType());
row.put("cslProjectId", proj.getId());
row.put("cslProjectName", proj.getName());
row.put("cslStage", proj.getStage());
row.put("client", proj.getClient());
if (lk.getBidId() != null) {
bidRepo.findById(lk.getBidId()).ifPresent(b -> {
row.put("bidId", b.getId());
row.put("bidProjectName", b.getProjectName());
row.put("bidStatus", b.getStatus());
row.put("bidAmount", b.getBidAmount());
});
}
if (lk.getOpportunityId() != null) {
opportunityRepo.findById(lk.getOpportunityId()).ifPresent(o -> {
row.put("opportunityId", o.getId());
row.put("opportunityName", o.getName());
row.put("opportunityStage", o.getStage());
row.put("opportunityAmount", o.getAmount());
});
}
result.add(row);
}
return ApiResp.ok(result);
}
// ===== ③ 复购机会系统内提醒(应用内兜底,无外部推送) =====
/**
* GET /repurchase-alerts -> 输出系统内扫描到的复购机会提醒清单。
* 逻辑:超过N个月无新咨询项目的老客户(复购机会提醒列表)。
* 前端可定期轮询此接口展示系统内提醒。
*/
@GetMapping("/repurchase-alerts")
public ApiResp<List<Map<String, Object>>> repurchaseAlerts(
@RequestParam(defaultValue = "6") int months) {
LocalDate cutoff = LocalDate.now().minusMonths(months);
List<CslProject> allProjects = cslProjectRepo.findAll();
Map<String, String[]> byClient = new LinkedHashMap<>();
for (CslProject p : allProjects) {
String client = p.getClient() == null || p.getClient().isBlank() ? "未知客户" : p.getClient();
String date = p.getCreatedDate() == null ? "2000-01-01" : p.getCreatedDate();
String name = p.getName() == null ? "" : p.getName();
if (!byClient.containsKey(client) || date.compareTo(byClient.get(client)[0]) > 0) {
byClient.put(client, new String[]{date, name,
String.valueOf(Integer.parseInt(byClient.getOrDefault(client, new String[]{"", "", "0"})[2]) + 1)});
} else {
String[] agg = byClient.get(client);
agg[2] = String.valueOf(Integer.parseInt(agg[2]) + 1);
}
}
List<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, String[]> e : byClient.entrySet()) {
String[] agg = e.getValue();
LocalDate lastDate;
try {
lastDate = LocalDate.parse(agg[0]);
} catch (Exception ex) {
lastDate = LocalDate.of(2000, 1, 1);
}
if (lastDate.isBefore(cutoff)) {
long monthsSince = ChronoUnit.MONTHS.between(lastDate, LocalDate.now());
Map<String, Object> row = new LinkedHashMap<>();
row.put("client", e.getKey());
row.put("lastProjectDate", agg[0]);
row.put("lastProjectName", agg[1]);
row.put("completedProjects", Integer.parseInt(agg[2]));
row.put("monthsSinceLastProject", monthsSince);
row.put("alertMessage", "客户「" + e.getKey() + "」已 " + monthsSince + " 个月无新咨询项目,请主动跟进复购商机");
row.put("urgency", monthsSince >= 12 ? "" : monthsSince >= 9 ? "" : "");
result.add(row);
}
}
result.sort((a, b) -> Long.compare((long) b.get("monthsSinceLastProject"), (long) a.get("monthsSinceLastProject")));
return ApiResp.ok(result);
}
/**
* 每日 09:00 扫描复购机会,写日志(应用内定时扫描,无需外部推送)。
* 当前以日志输出为主,未来可接驳系统内通知表(notification/announcement)。
*/
@Scheduled(cron = "0 0 9 * * ?")
@Transactional(readOnly = true)
public void scheduledRepurchaseScan() {
LocalDate cutoff = LocalDate.now().minusMonths(6);
List<CslProject> allProjects = cslProjectRepo.findAll();
Map<String, String> lastDateByClient = new LinkedHashMap<>();
for (CslProject p : allProjects) {
String client = p.getClient() == null || p.getClient().isBlank() ? "未知客户" : p.getClient();
String date = p.getCreatedDate() == null ? "2000-01-01" : p.getCreatedDate();
lastDateByClient.merge(client, date, (a, b2) -> a.compareTo(b2) >= 0 ? a : b2);
}
int alertCount = 0;
for (Map.Entry<String, String> e : lastDateByClient.entrySet()) {
try {
LocalDate last = LocalDate.parse(e.getValue());
if (last.isBefore(cutoff)) {
alertCount++;
long months = ChronoUnit.MONTHS.between(last, LocalDate.now());
System.out.println("[BidKnowledge][RepurchaseAlert] 客户「" + e.getKey() + "」已 " + months + " 个月无新咨询项目");
}
} catch (Exception ignored) {
}
}
System.out.println("[BidKnowledge][RepurchaseScan] 扫描完成,共 " + alertCount + " 个客户需跟进复购");
}
}