恢复点(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>
181 lines
8.2 KiB
Java
181 lines
8.2 KiB
Java
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<CategoryShare> categoryShares,
|
|
FoodInventory foodInventory, ServiceKpi serviceKpi) {
|
|
}
|
|
|
|
@GetMapping
|
|
public ApiResp<Dashboard> dashboard(@RequestParam(required = false) Integer year) {
|
|
int yr = year == null ? java.time.LocalDate.now().getYear() : year;
|
|
List<LogisticsBudget> budgets = budgetRepo.findByYear(yr);
|
|
|
|
BigDecimal totalBudget = BigDecimal.ZERO;
|
|
BigDecimal totalUsed = BigDecimal.ZERO;
|
|
int overCount = 0;
|
|
// 按科目合并(同科目多条预算合并),保留首见顺序。
|
|
Map<String, BigDecimal[]> 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<CategoryShare> shares = new ArrayList<>();
|
|
for (Map.Entry<String, BigDecimal[]> 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;
|
|
}
|
|
}
|
|
}
|