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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -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;
}
}
}