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 byStatus, List byWorkshop) { } // ---------- 趋势 ---------- public record TrendPoint(String month, double actualQty, double planQty) { } public record OpsKpi(WaterKpi water, ProductionKpi production, List productionTrend) { } /** 已完工/已入库视为「已完成」一档(产线产量已确认)。 */ private static final java.util.Set DONE = java.util.Set.of("已完工", "已入库"); private static final java.util.Set IN_PROGRESS = java.util.Set.of("生产中", "质检中"); @GetMapping public ApiResp 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 byStatus = new LinkedHashMap<>(); Map 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 rows = new ArrayList<>(); for (Map.Entry 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 productionTrend() { Map 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 out = new ArrayList<>(); for (Map.Entry 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; } }