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 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( @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 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 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 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 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>> stats() { List all = knowledgeRepo.findAll(); Map byType = new LinkedHashMap<>(); Map scoreMap = new LinkedHashMap<>(); Map 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> result = new ArrayList<>(); for (Map.Entry 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 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> topViewed() { List 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 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>> listLinks( @RequestParam(required = false) Long cslProjectId, @RequestParam(required = false) Long bidId) { List links; if (cslProjectId != null) { links = cslBidLinkRepo.findByCslProjectId(cslProjectId); } else if (bidId != null) { links = cslBidLinkRepo.findByBidId(bidId); } else { links = cslBidLinkRepo.findAll(); } List> result = new ArrayList<>(); for (CslBidLink lk : links) { Map 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 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>> accurateLinkView( @RequestParam(required = false) String client) { List all = cslBidLinkRepo.findAll(); List> 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 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>> repurchaseAlerts( @RequestParam(defaultValue = "6") int months) { LocalDate cutoff = LocalDate.now().minusMonths(months); List allProjects = cslProjectRepo.findAll(); Map 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> result = new ArrayList<>(); for (Map.Entry 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 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 allProjects = cslProjectRepo.findAll(); Map 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 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 + " 个客户需跟进复购"); } }