恢复点(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>
485 lines
22 KiB
Java
485 lines
22 KiB
Java
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 + " 个客户需跟进复购");
|
|
}
|
|
}
|