package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.Bid; import com.kaidi.oa.domain.BidLossCase; import com.kaidi.oa.repository.BidLossCaseRepository; import com.kaidi.oa.repository.BidRepository; import com.kaidi.oa.repository.MarketExpenseRepository; 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.math.BigDecimal; import java.math.RoundingMode; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 市场部·投标分析报表 + 丢标案例库(招投标管理深化)。 * * 一、丢标案例库(BidLossCase): * CRUD + POST /{bidId}/record-loss 从已有投标快速登记丢标原因与中标方信息。 * 支持按 lossReason/region/projectType 筛查。 * * 二、投标分析聚合(只读): * GET /stats : 总体统计(投标总数/中标数/中标率/平均下浮率/平均报价)。 * GET /by-industry : 按行业分维度中标率分布。 * GET /by-region : 按区域中标率分布。 * GET /loss-reasons : 丢标原因分布统计。 * GET /roi : 投入产出比(招标费用/市场费 vs 中标合同额)。 * GET /special-items : 含特殊项的开标记录(疑似围标/串标预警)。 */ @RestController @RequestMapping("/api/oa/crm-bid-analytics") public class CrmBidAnalyticsController { private final BidRepository bidRepo; private final BidLossCaseRepository lossRepo; private final MarketExpenseRepository expenseRepo; public CrmBidAnalyticsController(BidRepository bidRepo, BidLossCaseRepository lossRepo, MarketExpenseRepository expenseRepo) { this.bidRepo = bidRepo; this.lossRepo = lossRepo; this.expenseRepo = expenseRepo; } // ---------- 丢标案例库 CRUD ---------- @GetMapping("/loss-cases") public ApiResp> listLossCases( @RequestParam(required = false) String lossReason, @RequestParam(required = false) String projectType, @RequestParam(required = false) String region) { if (lossReason != null && !lossReason.isBlank()) { return ApiResp.ok(lossRepo.findByLossReason(lossReason)); } if (projectType != null && !projectType.isBlank()) { return ApiResp.ok(lossRepo.findByProjectType(projectType)); } if (region != null && !region.isBlank()) { return ApiResp.ok(lossRepo.findByRegion(region)); } return ApiResp.ok(lossRepo.findAll()); } @GetMapping("/loss-cases/{id}") public ApiResp getLossCase(@PathVariable Long id) { return ApiResp.ok(lossRepo.findById(id) .orElseThrow(() -> new NotFoundException("丢标案例不存在:" + id))); } public record LossCaseRequest( Long bidId, String projectName, String tenderee, String projectType, String region, Double ourBidAmount, Double winnerBidAmount, Double controlPrice, Double ourDiscountPct, Double winnerDiscountPct, String openDate, String lossReason, String winnerName, String lessons, Boolean specialItemFlag, String owner) { } @PostMapping("/loss-cases") @Transactional public ApiResp createLossCase(@RequestBody LossCaseRequest req) { if (req.lossReason() == null || req.lossReason().isBlank()) { throw new ApiException(400, "丢标原因(lossReason) 不能为空"); } if (req.bidId() != null && lossRepo.existsByBidId(req.bidId())) { throw new ApiException(409, "该投标项目已有丢标案例记录,请勿重复建档"); } BidLossCase c = buildLossCase(req); return ApiResp.ok(lossRepo.save(c)); } /** * POST /loss-cases/from-bid/{bidId} -> 从已有投标(status=未中标)快速登记丢标案例, * 自动从 Bid 继承项目名/招标人/类型/控制价/我方报价。 */ @PostMapping("/loss-cases/from-bid/{bidId}") @Transactional public ApiResp recordLossFromBid(@PathVariable Long bidId, @RequestBody LossCaseRequest req) { Bid bid = bidRepo.findById(bidId) .orElseThrow(() -> new NotFoundException("投标项目不存在:" + bidId)); if (!"未中标".equals(bid.getStatus())) { throw new ApiException(400, "只有状态为「未中标」的投标可建丢标案例,当前状态:" + bid.getStatus()); } if (lossRepo.existsByBidId(bidId)) { throw new ApiException(409, "该投标项目已有丢标案例记录"); } if (req.lossReason() == null || req.lossReason().isBlank()) { throw new ApiException(400, "丢标原因(lossReason) 不能为空"); } BidLossCase c = new BidLossCase(); c.setBidId(bidId); // 从 Bid 继承基础信息 c.setProjectName(bid.getProjectName()); c.setTenderee(bid.getTenderee()); c.setProjectType(bid.getProjectType()); c.setOurBidAmount(Money.nz(bid.getBidAmount())); c.setControlPrice(Money.nz(bid.getControlPrice())); // 从请求覆盖 if (req.region() != null) c.setRegion(req.region()); if (req.winnerBidAmount() != null) c.setWinnerBidAmount(Money.of(req.winnerBidAmount())); if (req.ourDiscountPct() != null) c.setOurDiscountPct(req.ourDiscountPct()); if (req.winnerDiscountPct() != null) c.setWinnerDiscountPct(req.winnerDiscountPct()); c.setOpenDate(req.openDate() != null ? req.openDate() : bid.getOpenDate()); c.setLossReason(req.lossReason()); if (req.winnerName() != null) c.setWinnerName(req.winnerName()); // priceGap = 我方报价 - 中标价 if (req.winnerBidAmount() != null) { c.setPriceGap(Money.sub(c.getOurBidAmount(), Money.of(req.winnerBidAmount()))); } if (req.lessons() != null) c.setLessons(req.lessons()); c.setSpecialItemFlag(req.specialItemFlag() != null && req.specialItemFlag()); c.setOwner(req.owner() != null ? req.owner() : bid.getOwner()); c.setCreatedAt(Instant.now()); return ApiResp.ok(lossRepo.save(c)); } @PatchMapping("/loss-cases/{id}") @Transactional public ApiResp updateLossCase(@PathVariable Long id, @RequestBody LossCaseRequest req) { BidLossCase c = lossRepo.findById(id) .orElseThrow(() -> new NotFoundException("丢标案例不存在:" + id)); if (req.projectName() != null) c.setProjectName(req.projectName()); if (req.tenderee() != null) c.setTenderee(req.tenderee()); if (req.projectType() != null) c.setProjectType(req.projectType()); if (req.region() != null) c.setRegion(req.region()); if (req.ourBidAmount() != null) c.setOurBidAmount(Money.of(req.ourBidAmount())); if (req.winnerBidAmount() != null) c.setWinnerBidAmount(Money.of(req.winnerBidAmount())); if (req.controlPrice() != null) c.setControlPrice(Money.of(req.controlPrice())); if (req.ourDiscountPct() != null) c.setOurDiscountPct(req.ourDiscountPct()); if (req.winnerDiscountPct() != null) c.setWinnerDiscountPct(req.winnerDiscountPct()); if (req.openDate() != null) c.setOpenDate(req.openDate()); if (req.lossReason() != null) c.setLossReason(req.lossReason()); if (req.winnerName() != null) c.setWinnerName(req.winnerName()); if (req.winnerBidAmount() != null && c.getOurBidAmount() != null) { c.setPriceGap(Money.sub(c.getOurBidAmount(), Money.of(req.winnerBidAmount()))); } if (req.lessons() != null) c.setLessons(req.lessons()); if (req.specialItemFlag() != null) c.setSpecialItemFlag(req.specialItemFlag()); if (req.owner() != null) c.setOwner(req.owner()); return ApiResp.ok(lossRepo.save(c)); } @DeleteMapping("/loss-cases/{id}") public ApiResp deleteLossCase(@PathVariable Long id) { if (!lossRepo.existsById(id)) throw new NotFoundException("丢标案例不存在:" + id); lossRepo.deleteById(id); return ApiResp.ok(null); } private BidLossCase buildLossCase(LossCaseRequest req) { BidLossCase c = new BidLossCase(); c.setBidId(req.bidId()); c.setProjectName(req.projectName()); c.setTenderee(req.tenderee()); c.setProjectType(req.projectType()); c.setRegion(req.region()); c.setOurBidAmount(Money.of(req.ourBidAmount())); c.setWinnerBidAmount(Money.of(req.winnerBidAmount())); c.setControlPrice(Money.of(req.controlPrice())); c.setOurDiscountPct(req.ourDiscountPct()); c.setWinnerDiscountPct(req.winnerDiscountPct()); c.setOpenDate(req.openDate()); c.setLossReason(req.lossReason()); c.setWinnerName(req.winnerName()); if (req.ourBidAmount() != null && req.winnerBidAmount() != null) { c.setPriceGap(Money.sub(Money.of(req.ourBidAmount()), Money.of(req.winnerBidAmount()))); } c.setLessons(req.lessons()); c.setSpecialItemFlag(req.specialItemFlag() != null && req.specialItemFlag()); c.setOwner(req.owner()); c.setCreatedAt(Instant.now()); return c; } // ---------- 投标分析统计聚合 ---------- public record BidStats(long total, long won, long lost, long waived, long inProgress, double winRate, double avgDiscountPct, double avgBidAmount, String year) { } /** * GET /stats?year=2026 -> 总体统计。winRate = won/total(非放弃)。 */ @GetMapping("/stats") public ApiResp stats(@RequestParam(required = false) String year) { List bids = bidRepo.findAll(); if (year != null && !year.isBlank()) { bids = bids.stream() .filter(b -> year.equals(yearOf(b.getBidDate()))) .toList(); } long won = bids.stream().filter(b -> "中标".equals(b.getStatus())).count(); long lost = bids.stream().filter(b -> "未中标".equals(b.getStatus())).count(); long waived = bids.stream().filter(b -> "废标".equals(b.getStatus())).count(); long inProgress = bids.stream().filter(b -> "投标准备".equals(b.getStatus()) || "已投标".equals(b.getStatus())).count(); long effective = won + lost; // 放弃不算基数 double winRate = effective == 0 ? 0.0 : Math.round(won * 1000.0 / effective) / 10.0; double avgDiscount = bids.stream() .filter(b -> Money.nz(b.getControlPrice()).signum() > 0 && Money.nz(b.getBidAmount()).signum() > 0) .mapToDouble(b -> { double ctrl = Money.nz(b.getControlPrice()).doubleValue(); double bid = Money.nz(b.getBidAmount()).doubleValue(); return (ctrl - bid) / ctrl * 100; }).average().orElse(0.0); double avgBidAmt = bids.stream() .filter(b -> Money.nz(b.getBidAmount()).signum() > 0) .mapToDouble(b -> Money.nz(b.getBidAmount()).doubleValue()) .average().orElse(0.0); return ApiResp.ok(new BidStats(bids.size(), won, lost, waived, inProgress, Math.round(winRate * 10) / 10.0, Math.round(avgDiscount * 10) / 10.0, Math.round(avgBidAmt * 100) / 100.0, year)); } public record DimRow(String dimension, long total, long won, double winRate, double avgDiscountPct) { } /** * GET /by-industry -> 按行业分维度中标率分布。 */ @GetMapping("/by-industry") public ApiResp> byIndustry(@RequestParam(required = false) String year) { return ApiResp.ok(groupByDim(filteredBids(year), Bid::getProjectType)); } /** * GET /by-region -> 按区域(orgUnit)中标率分布。 */ @GetMapping("/by-region") public ApiResp> byRegion(@RequestParam(required = false) String year) { return ApiResp.ok(groupByDim(filteredBids(year), Bid::getOrgUnit)); } /** * GET /loss-reasons -> 丢标原因分布统计(来自丢标案例库)。 */ @GetMapping("/loss-reasons") public ApiResp>> lossReasons() { Map> byReason = new LinkedHashMap<>(); for (BidLossCase c : lossRepo.findAll()) { String reason = c.getLossReason() == null ? "未登记" : c.getLossReason(); byReason.computeIfAbsent(reason, k -> new ArrayList<>()).add(c); } List> result = new ArrayList<>(); long total = lossRepo.count(); for (Map.Entry> e : byReason.entrySet()) { Map row = new LinkedHashMap<>(); row.put("reason", e.getKey()); row.put("count", e.getValue().size()); row.put("pct", total == 0 ? 0.0 : Math.round(e.getValue().size() * 1000.0 / total) / 10.0); // 平均价格差距 double avgGap = e.getValue().stream() .filter(c -> c.getPriceGap() != null && c.getPriceGap().signum() != 0) .mapToDouble(c -> c.getPriceGap().doubleValue()) .average().orElse(0.0); row.put("avgPriceGap", Math.round(avgGap * 100) / 100.0); result.add(row); } result.sort((a, b) -> Integer.compare((int) b.get("count"), (int) a.get("count"))); return ApiResp.ok(result); } /** * GET /roi -> 投入产出比(招标/市场费用 vs 中标合同额)。 */ @GetMapping("/roi") public ApiResp> roi(@RequestParam(required = false) String year) { List won = filteredBids(year).stream() .filter(b -> "中标".equals(b.getStatus())).toList(); BigDecimal totalWon = won.stream() .map(b -> Money.nz(b.getBidAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); // 市场费用取 MarketExpense 表累计 BigDecimal totalExpense = expenseRepo.findAll().stream() .filter(e -> year == null || year.isBlank() || year.equals(yearOf(e.getExpenseDate()))) .map(e -> Money.nz(e.getAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); double roi = totalExpense.signum() == 0 ? 0.0 : totalWon.divide(totalExpense, 4, RoundingMode.HALF_UP).doubleValue(); Map result = new LinkedHashMap<>(); result.put("year", year); result.put("totalWonAmount", totalWon.doubleValue()); result.put("totalExpense", totalExpense.doubleValue()); result.put("roi", Math.round(roi * 100) / 100.0); result.put("wonCount", won.size()); return ApiResp.ok(result); } /** * GET /special-items -> 含特殊项的开标记录(疑似围标/串标预警)。 */ @GetMapping("/special-items") public ApiResp> specialItems() { return ApiResp.ok(lossRepo.findAll().stream() .filter(c -> Boolean.TRUE.equals(c.getSpecialItemFlag())) .sorted(Comparator.comparing(BidLossCase::getOpenDate, Comparator.nullsLast(Comparator.reverseOrder()))) .toList()); } // ---------- helpers ---------- private List filteredBids(String year) { List all = bidRepo.findAll(); if (year != null && !year.isBlank()) { return all.stream().filter(b -> year.equals(yearOf(b.getBidDate()))).toList(); } return all; } private static String yearOf(String date) { if (date == null || date.length() < 4) return null; return date.substring(0, 4); } private List groupByDim(List bids, java.util.function.Function key) { Map map = new LinkedHashMap<>(); // [total, won, discountSum, discountCount] for (Bid b : bids) { String dim = key.apply(b); if (dim == null || dim.isBlank()) dim = "未分类"; long[] arr = map.computeIfAbsent(dim, k -> new long[4]); if (!"投标准备".equals(b.getStatus()) && !"已投标".equals(b.getStatus())) { arr[0]++; if ("中标".equals(b.getStatus())) arr[1]++; if (Money.nz(b.getControlPrice()).signum() > 0 && Money.nz(b.getBidAmount()).signum() > 0) { double ctrl = Money.nz(b.getControlPrice()).doubleValue(); double bid = Money.nz(b.getBidAmount()).doubleValue(); arr[2] += (long) ((ctrl - bid) / ctrl * 1000); // 存千分比 arr[3]++; } } } List result = new ArrayList<>(); for (Map.Entry e : map.entrySet()) { long[] arr = e.getValue(); double wr = arr[0] == 0 ? 0.0 : Math.round(arr[1] * 1000.0 / arr[0]) / 10.0; double avgDiscount = arr[3] == 0 ? 0.0 : Math.round(arr[2] * 10.0 / arr[3]) / 100.0; result.add(new DimRow(e.getKey(), arr[0], arr[1], wr, avgDiscount)); } result.sort((a, b) -> Long.compare(b.total(), a.total())); return result; } }