恢复点(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>
169 lines
6.7 KiB
Java
169 lines
6.7 KiB
Java
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;
|
|
}
|
|
}
|