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,384 @@
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<List<BidLossCase>> 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<BidLossCase> 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<BidLossCase> 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<BidLossCase> 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<BidLossCase> 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<Void> 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<BidStats> stats(@RequestParam(required = false) String year) {
List<Bid> 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<List<DimRow>> byIndustry(@RequestParam(required = false) String year) {
return ApiResp.ok(groupByDim(filteredBids(year), Bid::getProjectType));
}
/**
* GET /by-region -> 按区域(orgUnit)中标率分布。
*/
@GetMapping("/by-region")
public ApiResp<List<DimRow>> byRegion(@RequestParam(required = false) String year) {
return ApiResp.ok(groupByDim(filteredBids(year), Bid::getOrgUnit));
}
/**
* GET /loss-reasons -> 丢标原因分布统计(来自丢标案例库)。
*/
@GetMapping("/loss-reasons")
public ApiResp<List<Map<String, Object>>> lossReasons() {
Map<String, List<BidLossCase>> byReason = new LinkedHashMap<>();
for (BidLossCase c : lossRepo.findAll()) {
String reason = c.getLossReason() == null ? "未登记" : c.getLossReason();
byReason.computeIfAbsent(reason, k -> new ArrayList<>()).add(c);
}
List<Map<String, Object>> result = new ArrayList<>();
long total = lossRepo.count();
for (Map.Entry<String, List<BidLossCase>> e : byReason.entrySet()) {
Map<String, Object> 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<Map<String, Object>> roi(@RequestParam(required = false) String year) {
List<Bid> 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<String, Object> 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<List<BidLossCase>> 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<Bid> filteredBids(String year) {
List<Bid> 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<DimRow> groupByDim(List<Bid> bids, java.util.function.Function<Bid, String> key) {
Map<String, long[]> 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<DimRow> result = new ArrayList<>();
for (Map.Entry<String, long[]> 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;
}
}