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,168 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.domain.WaterQualityRecord;
import com.kaidi.oa.domain.WorkOrder;
import com.kaidi.oa.repository.WaterQualityRecordRepository;
import com.kaidi.oa.repository.WorkOrderRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 运营KPI看板(只读聚合)。把生产运营两类台账客户端汇总成一屏可读的KPI:
* - 水质监测达标率:WaterQualityRecord 的 result==达标 占比,并按超标项给出风险计数;
* - 生产工单产量与完成率:WorkOrder 的 planQty/actualQty 汇总、各状态分布、按车间(workshop)
* 的计划/实际产量与完成率行;
* - 产量趋势:按 plannedDate 的「年-月」聚合的实际产量序列,供前端画趋势。
*
* 不新建任何实体;结果一律走 {@link ApiResp} 信封。数量/比率用 double(科学量/比率非金额,
* 不涉及钱的精度对账,符合 Money 工具的「数量留 double」口径)。
*
* 该端点读运营生产数据,注册到 SENSITIVE_READ_PREFIXES 不是必须(不含金额/PII),但作为
* 跨表聚合驾驶舱,按基路径 /api/oa/ops-kpi 走 default-deny(任意登录角色可读运营KPI即可)。
*/
@RestController
@RequestMapping("/api/oa/ops-kpi")
public class OpsKpiController {
private final WaterQualityRecordRepository waterRepo;
private final WorkOrderRepository workOrderRepo;
public OpsKpiController(WaterQualityRecordRepository waterRepo,
WorkOrderRepository workOrderRepo) {
this.waterRepo = waterRepo;
this.workOrderRepo = workOrderRepo;
}
// ---------- 水质达标 ----------
public record WaterKpi(long total, long compliant, long overLimit, double complianceRate) {
}
// ---------- 生产产量 ----------
public record WorkshopRow(String workshop, double planQty, double actualQty,
double completionRate, long orderCount) {
}
public record ProductionKpi(long orderCount, double planQtyTotal, double actualQtyTotal,
double completionRate, long completedCount, long inProgressCount,
Map<String, Long> byStatus, List<WorkshopRow> byWorkshop) {
}
// ---------- 趋势 ----------
public record TrendPoint(String month, double actualQty, double planQty) {
}
public record OpsKpi(WaterKpi water, ProductionKpi production, List<TrendPoint> productionTrend) {
}
/** 已完工/已入库视为「已完成」一档(产线产量已确认)。 */
private static final java.util.Set<String> DONE = java.util.Set.of("已完工", "已入库");
private static final java.util.Set<String> IN_PROGRESS = java.util.Set.of("生产中", "质检中");
@GetMapping
public ApiResp<OpsKpi> kpi() {
return ApiResp.ok(new OpsKpi(waterKpi(), productionKpi(), productionTrend()));
}
private WaterKpi waterKpi() {
long total = 0, compliant = 0, overLimit = 0;
for (WaterQualityRecord r : waterRepo.findAll()) {
total++;
if ("达标".equals(r.getResult())) {
compliant++;
} else if ("超标".equals(r.getResult())) {
overLimit++;
}
}
double rate = total == 0 ? 0 : round1(compliant * 100.0 / total);
return new WaterKpi(total, compliant, overLimit, rate);
}
private static final class WsAcc {
double plan = 0;
double actual = 0;
long count = 0;
}
private ProductionKpi productionKpi() {
double planTotal = 0, actualTotal = 0;
long orderCount = 0, completed = 0, inProgress = 0;
Map<String, Long> byStatus = new LinkedHashMap<>();
Map<String, WsAcc> byWorkshop = new LinkedHashMap<>();
for (WorkOrder w : workOrderRepo.findAll()) {
orderCount++;
planTotal += w.getPlanQty();
actualTotal += w.getActualQty();
String status = w.getStatus() == null ? "未知" : w.getStatus();
byStatus.merge(status, 1L, Long::sum);
if (DONE.contains(status)) {
completed++;
} else if (IN_PROGRESS.contains(status)) {
inProgress++;
}
String ws = w.getWorkshop() == null || w.getWorkshop().isBlank() ? "未分配" : w.getWorkshop();
WsAcc acc = byWorkshop.computeIfAbsent(ws, k -> new WsAcc());
acc.plan += w.getPlanQty();
acc.actual += w.getActualQty();
acc.count++;
}
List<WorkshopRow> rows = new ArrayList<>();
for (Map.Entry<String, WsAcc> e : byWorkshop.entrySet()) {
WsAcc a = e.getValue();
double rate = a.plan == 0 ? 0 : round1(a.actual * 100.0 / a.plan);
rows.add(new WorkshopRow(e.getKey(), round1(a.plan), round1(a.actual), rate, a.count));
}
double completionRate = planTotal == 0 ? 0 : round1(actualTotal * 100.0 / planTotal);
return new ProductionKpi(orderCount, round1(planTotal), round1(actualTotal), completionRate,
completed, inProgress, byStatus, rows);
}
/** 按计划日期(plannedDate)的「年-月」聚合实际/计划产量;月份按时间升序。 */
private List<TrendPoint> productionTrend() {
Map<String, double[]> byMonth = new TreeMap<>();
for (WorkOrder w : workOrderRepo.findAll()) {
String month = monthOf(w.getPlannedDate());
if (month == null) {
continue;
}
double[] acc = byMonth.computeIfAbsent(month, k -> new double[2]);
acc[0] += w.getActualQty();
acc[1] += w.getPlanQty();
}
List<TrendPoint> out = new ArrayList<>();
for (Map.Entry<String, double[]> e : byMonth.entrySet()) {
out.add(new TrendPoint(e.getKey(), round1(e.getValue()[0]), round1(e.getValue()[1])));
}
return out;
}
/** "2026-07-10" -> "2026-07";非法/空返回 null(跳过该行,不污染趋势)。 */
private static String monthOf(String date) {
if (date == null || date.length() < 7) {
return null;
}
String m = date.trim().substring(0, 7);
// 形如 yyyy-MM 才接受。
if (m.length() == 7 && m.charAt(4) == '-') {
return m;
}
return null;
}
private static double round1(double v) {
return Math.round(v * 10.0) / 10.0;
}
}