package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.domain.FoodBatch; import com.kaidi.oa.domain.LogisticsBudget; import com.kaidi.oa.domain.LogisticsExpense; import com.kaidi.oa.domain.LogisticsTicket; import com.kaidi.oa.repository.FoodBatchRepository; import com.kaidi.oa.repository.LogisticsBudgetRepository; import com.kaidi.oa.repository.LogisticsExpenseRepository; import com.kaidi.oa.repository.LogisticsTicketRepository; import org.springframework.web.bind.annotation.GetMapping; 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.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 行政/后勤·报表与看板:后勤成本看板(只读聚合,无独立实体)。一次拉齐多源数据,喂给前端看板: * - 各后勤科目预算执行率与占比(按 {@link LogisticsBudget} usedAmount 占总执行额); * - 食材库存现值与临期占比(按 {@link FoodBatch} 现存量×单价,临期=7天内到期); * - 服务工单完成率与平均满意度(按 {@link LogisticsTicket} KPI); * - 费用按科目占比(按 {@link LogisticsExpense} 已入账金额)。 * * 跨表聚合且含金额,列入敏感读前缀(ADMIN/APPROVER)。 */ @RestController @RequestMapping("/api/oa/logistics-dashboard") public class LogisticsDashboardController { private final LogisticsBudgetRepository budgetRepo; private final LogisticsExpenseRepository expenseRepo; private final FoodBatchRepository batchRepo; private final LogisticsTicketRepository ticketRepo; public LogisticsDashboardController(LogisticsBudgetRepository budgetRepo, LogisticsExpenseRepository expenseRepo, FoodBatchRepository batchRepo, LogisticsTicketRepository ticketRepo) { this.budgetRepo = budgetRepo; this.expenseRepo = expenseRepo; this.batchRepo = batchRepo; this.ticketRepo = ticketRepo; } public record CategoryShare(String category, double budget, double used, double executionRate, double sharePct) { } public record FoodInventory(double inventoryValue, int inStockBatches, int nearExpiryBatches, double nearExpiryValue, double wasteRatePct, double totalInboundQty, double totalScrapQty) { } public record ServiceKpi(int totalTickets, int finished, double finishRate, double avgSatisfaction, int openTickets) { } public record Dashboard(int year, double totalBudget, double totalUsed, double overallExecutionRate, int overBudgetCount, List categoryShares, FoodInventory foodInventory, ServiceKpi serviceKpi) { } @GetMapping public ApiResp dashboard(@RequestParam(required = false) Integer year) { int yr = year == null ? java.time.LocalDate.now().getYear() : year; List budgets = budgetRepo.findByYear(yr); BigDecimal totalBudget = BigDecimal.ZERO; BigDecimal totalUsed = BigDecimal.ZERO; int overCount = 0; // 按科目合并(同科目多条预算合并),保留首见顺序。 Map byCat = new LinkedHashMap<>(); for (LogisticsBudget b : budgets) { String cat = b.getCategory() == null ? "其他" : b.getCategory(); BigDecimal[] acc = byCat.computeIfAbsent(cat, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); acc[0] = Money.add(acc[0], b.getAmount()); acc[1] = Money.add(acc[1], b.getUsedAmount()); totalBudget = Money.add(totalBudget, b.getAmount()); totalUsed = Money.add(totalUsed, b.getUsedAmount()); if (Money.gt(Money.nz(b.getUsedAmount()), Money.nz(b.getAmount()))) { overCount++; } } List shares = new ArrayList<>(); for (Map.Entry e : byCat.entrySet()) { BigDecimal amt = e.getValue()[0]; BigDecimal used = e.getValue()[1]; double execRate = amt.signum() == 0 ? 0 : used.divide(amt, 4, RoundingMode.HALF_UP).doubleValue() * 100.0; double sharePct = totalUsed.signum() == 0 ? 0 : used.divide(totalUsed, 4, RoundingMode.HALF_UP).doubleValue() * 100.0; shares.add(new CategoryShare(e.getKey(), amt.doubleValue(), used.doubleValue(), round2(execRate), round2(sharePct))); } shares.sort((a, b) -> Double.compare(b.used(), a.used())); double overallExec = totalBudget.signum() == 0 ? 0 : totalUsed.divide(totalBudget, 4, RoundingMode.HALF_UP).doubleValue() * 100.0; // 食材库存现值 + 临期(7天内)+ 损耗率统计。 BigDecimal invValue = BigDecimal.ZERO; BigDecimal nearValue = BigDecimal.ZERO; int inStock = 0; int nearCnt = 0; double totalInboundQty = 0; double totalScrapQty = 0; java.time.LocalDate today = java.time.LocalDate.now(); for (FoodBatch fb : batchRepo.findAll()) { double qty = fb.getQty() == null ? 0 : fb.getQty(); double remain = fb.getRemainQty() == null ? 0 : fb.getRemainQty(); if (qty > 0) totalInboundQty += qty; if ("已报损".equals(fb.getStatus()) && qty > 0) totalScrapQty += qty; if ("在库".equals(fb.getStatus()) && remain > 0) { inStock++; BigDecimal val = Money.of(Money.nz(fb.getUnitPrice()).multiply(BigDecimal.valueOf(remain))); invValue = Money.add(invValue, val); java.time.LocalDate exp = parseDateOrNull(fb.getExpiryDate()); if (exp != null) { long d = ChronoUnit.DAYS.between(today, exp); if (d <= 7) { nearCnt++; nearValue = Money.add(nearValue, val); } } } } double wasteRate = totalInboundQty == 0 ? 0 : round2(totalScrapQty / totalInboundQty * 100.0); FoodInventory food = new FoodInventory(invValue.doubleValue(), inStock, nearCnt, nearValue.doubleValue(), wasteRate, round2(totalInboundQty), round2(totalScrapQty)); // 服务工单 KPI。 int total = 0; int finished = 0; int open = 0; int rated = 0; long satSum = 0; for (LogisticsTicket t : ticketRepo.findAll()) { total++; String st = t.getStatus() == null ? "" : t.getStatus(); if ("已完成".equals(st) || "已评价".equals(st)) { finished++; } else if (!"已关闭".equals(st) && !"已驳回".equals(st)) { open++; } if (t.getSatisfaction() != null && t.getSatisfaction() >= 1) { rated++; satSum += t.getSatisfaction(); } } double finishRate = total == 0 ? 0 : round2((double) finished / total * 100.0); double avgSat = rated == 0 ? 0 : round2((double) satSum / rated); ServiceKpi kpi = new ServiceKpi(total, finished, finishRate, avgSat, open); return ApiResp.ok(new Dashboard(yr, totalBudget.doubleValue(), totalUsed.doubleValue(), round2(overallExec), overCount, shares, food, kpi)); } private static double round2(double v) { return Math.round(v * 100d) / 100d; } private static java.time.LocalDate parseDateOrNull(String s) { if (s == null || s.isBlank()) { return null; } try { return java.time.LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); } catch (Exception e) { return null; } } }