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:
@@ -0,0 +1,180 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user