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,378 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.domain.BranchCostEntry;
import com.kaidi.oa.domain.Contract;
import com.kaidi.oa.domain.ExpenseClaim;
import com.kaidi.oa.domain.OrgBranch;
import com.kaidi.oa.repository.BranchCostEntryRepository;
import com.kaidi.oa.repository.ContractRepository;
import com.kaidi.oa.repository.ExpenseClaimRepository;
import com.kaidi.oa.repository.OrgBranchRepository;
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.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 分公司损益表月度/分期拆分视图(市场部·费用与预算控制 缺口补完)。
*
* 审计缺口:损益表缺分期/月度拆分视图——BranchPnlController 与 BranchFinStatementController 均按全年
* 汇总,无法按月查看收入/成本/费用/利润趋势。
*
* 本控制器提供:
* 1. GET /monthly?orgUnit=&year= → 指定机构某年度按月汇聚损益(12行)
* 2. GET /monthly-all?year= → 全部机构某年度月度损益汇聚(多机构×12月)
* 3. GET /period?orgUnit=&period= → 指定机构指定期间(如 2026-01~2026-06)区间聚合
*
* 收入口径:合同回款额 paidAmount,取 Contract.payDate 前 7 位(yyyy-MM)作为月份归属;
* 成本口径(优先):BranchCostEntry 独立成本归集明细,按 costPeriod 归月;
* 若某月无独立成本录入,回落到合同 invoicedAmount(开票额),按 invoiceDate 归月;
* 费用口径:ExpenseClaimstatus=已报销)按 expenseDate 前 7 位归月。
*
* 读口含机密财务,已登记 AuthInterceptor SENSITIVE_READ_PREFIXES/api/oa/branch-monthly-pnl)。
*/
@RestController
@RequestMapping("/api/oa/branch-monthly-pnl")
public class BranchMonthlyPnlController {
private final OrgBranchRepository orgBranchRepo;
private final ContractRepository contractRepo;
private final ExpenseClaimRepository expenseRepo;
private final BranchCostEntryRepository costEntryRepo;
public BranchMonthlyPnlController(OrgBranchRepository orgBranchRepo,
ContractRepository contractRepo,
ExpenseClaimRepository expenseRepo,
BranchCostEntryRepository costEntryRepo) {
this.orgBranchRepo = orgBranchRepo;
this.contractRepo = contractRepo;
this.expenseRepo = expenseRepo;
this.costEntryRepo = costEntryRepo;
}
// ============================================================
// DTO
// ============================================================
public record MonthlyPnlRow(
String period, // yyyy-MM
double income,
double cost,
double expense,
double profit,
double profitRate, // 利润率 %
boolean costFromEntry // true=来自独立成本归集,false=回落 invoicedAmount 替代
) {
}
public record MonthlyPnlResult(
String orgUnit, String year,
List<MonthlyPnlRow> monthly,
double totalIncome, double totalCost, double totalExpense, double totalProfit) {
}
public record AllBranchMonthRow(
String orgUnit, String period,
double income, double cost, double expense, double profit) {
}
// ============================================================
// 1. 单机构月度损益
// ============================================================
@GetMapping("/monthly")
public ApiResp<MonthlyPnlResult> monthly(
@RequestParam String orgUnit,
@RequestParam(required = false, defaultValue = "") String year) {
if (orgUnit == null || orgUnit.isBlank()) {
throw new ApiException(400, "orgUnit 不能为空");
}
// 解析目标机构
OrgBranch branch = findBranch(orgUnit);
String effectiveOrgUnit = branch != null ? branch.getName() : orgUnit;
String effectiveCode = branch != null ? branch.getCode() : orgUnit;
String resolvedYear = (year == null || year.isBlank())
? String.valueOf(java.time.LocalDate.now().getYear()) : year;
// ---- 收入:合同回款,按 payDate 前 7 位 ----
// 使用 signDate 字段(Contract 有 signDate,无 payDate;用 signDate 近似归月,实际项目可加 payDate)
Map<String, BigDecimal> incomeByMonth = new TreeMap<>();
for (Contract ct : contractRepo.findAll()) {
if (!matchYear(ct.getSignDate(), resolvedYear)) continue;
if (!hitBranch(ct.getCompanySubject(), effectiveOrgUnit, effectiveCode)) continue;
String month = toMonth(ct.getSignDate());
if (month == null) continue;
incomeByMonth.merge(month, Money.nz(ct.getPaidAmount()), Money::add);
}
// ---- 成本:优先 BranchCostEntry,按 costPeriodyyyy-MM);无则按合同 invoicedAmount 回落 ----
Map<String, BigDecimal> costByMonth = new TreeMap<>();
Map<String, Boolean> costFromEntryFlag = new LinkedHashMap<>();
List<BranchCostEntry> costEntries = costEntryRepo.findByOrgUnitAndYear(effectiveOrgUnit, resolvedYear);
if (costEntries.isEmpty()) {
// 尝试 code
costEntries = costEntryRepo.findByOrgUnitAndYear(effectiveCode, resolvedYear);
}
if (!costEntries.isEmpty()) {
for (BranchCostEntry ce : costEntries) {
String p = ce.getCostPeriod();
if (p == null || p.length() < 7) continue;
String month = p.substring(0, 7);
costByMonth.merge(month, Money.nz(ce.getCostAmount()), Money::add);
costFromEntryFlag.put(month, true);
}
} else {
// 回落:合同 invoicedAmount 按 signDate 归月
for (Contract ct : contractRepo.findAll()) {
if (!matchYear(ct.getSignDate(), resolvedYear)) continue;
if (!hitBranch(ct.getCompanySubject(), effectiveOrgUnit, effectiveCode)) continue;
String month = toMonth(ct.getSignDate());
if (month == null) continue;
costByMonth.merge(month, Money.nz(ct.getInvoicedAmount()), Money::add);
costFromEntryFlag.put(month, false);
}
}
// ---- 费用:ExpenseClaim 已报销,按 expenseDate 归月 ----
Map<String, BigDecimal> expByMonth = new TreeMap<>();
for (ExpenseClaim ec : expenseRepo.findAll()) {
if (!"已报销".equals(ec.getStatus())) continue;
if (!matchYear(ec.getExpenseDate(), resolvedYear)) continue;
if (!hitBranch(ec.getDept(), effectiveOrgUnit, effectiveCode)
&& !hitBranch(ec.getCompanySubject(), effectiveOrgUnit, effectiveCode)) continue;
String month = toMonth(ec.getExpenseDate());
if (month == null) continue;
expByMonth.merge(month, Money.nz(ec.getAmount()), Money::add);
}
// ---- 合并成 12 行 ----
List<MonthlyPnlRow> rows = new ArrayList<>();
BigDecimal totIncome = BigDecimal.ZERO;
BigDecimal totCost = BigDecimal.ZERO;
BigDecimal totExp = BigDecimal.ZERO;
BigDecimal totProfit = BigDecimal.ZERO;
for (int m = 1; m <= 12; m++) {
String period = resolvedYear + "-" + String.format("%02d", m);
BigDecimal inc = incomeByMonth.getOrDefault(period, BigDecimal.ZERO);
BigDecimal cost = costByMonth.getOrDefault(period, BigDecimal.ZERO);
BigDecimal exp = expByMonth.getOrDefault(period, BigDecimal.ZERO);
BigDecimal profit = Money.sub(Money.sub(inc, cost), exp);
double pr = inc.signum() == 0 ? 0.0
: profit.divide(inc, 4, RoundingMode.HALF_UP).doubleValue() * 100.0;
boolean fromEntry = Boolean.TRUE.equals(costFromEntryFlag.get(period));
rows.add(new MonthlyPnlRow(period,
inc.doubleValue(), cost.doubleValue(), exp.doubleValue(),
profit.doubleValue(), r2(pr), fromEntry));
totIncome = Money.add(totIncome, inc);
totCost = Money.add(totCost, cost);
totExp = Money.add(totExp, exp);
totProfit = Money.add(totProfit, profit);
}
return ApiResp.ok(new MonthlyPnlResult(effectiveOrgUnit, resolvedYear, rows,
totIncome.doubleValue(), totCost.doubleValue(),
totExp.doubleValue(), totProfit.doubleValue()));
}
// ============================================================
// 2. 全部机构某年月度汇聚(可用于多机构横向对比看板)
// ============================================================
@GetMapping("/monthly-all")
public ApiResp<List<AllBranchMonthRow>> monthlyAll(
@RequestParam(required = false, defaultValue = "") String year) {
String resolvedYear = (year == null || year.isBlank())
? String.valueOf(java.time.LocalDate.now().getYear()) : year;
List<OrgBranch> branches = orgBranchRepo.findAll();
List<Contract> contracts = contractRepo.findAll();
List<ExpenseClaim> expenses = expenseRepo.findAll();
List<BranchCostEntry> costEntries = costEntryRepo.findByYear(resolvedYear);
List<AllBranchMonthRow> result = new ArrayList<>();
for (OrgBranch b : branches) {
Map<String, BigDecimal> incMap = new TreeMap<>();
Map<String, BigDecimal> costMap = new TreeMap<>();
Map<String, BigDecimal> expMap = new TreeMap<>();
for (Contract ct : contracts) {
if (!matchYear(ct.getSignDate(), resolvedYear)) continue;
if (!hit(ct.getCompanySubject(), b)) continue;
String m = toMonth(ct.getSignDate());
if (m == null) continue;
incMap.merge(m, Money.nz(ct.getPaidAmount()), Money::add);
}
// 成本:优先独立归集
List<BranchCostEntry> branchCosts = costEntries.stream()
.filter(ce -> hitEntry(ce.getOrgUnit(), b))
.toList();
if (!branchCosts.isEmpty()) {
for (BranchCostEntry ce : branchCosts) {
String p = ce.getCostPeriod();
if (p == null || p.length() < 7) continue;
costMap.merge(p.substring(0, 7), Money.nz(ce.getCostAmount()), Money::add);
}
} else {
for (Contract ct : contracts) {
if (!matchYear(ct.getSignDate(), resolvedYear)) continue;
if (!hit(ct.getCompanySubject(), b)) continue;
String m = toMonth(ct.getSignDate());
if (m == null) continue;
costMap.merge(m, Money.nz(ct.getInvoicedAmount()), Money::add);
}
}
for (ExpenseClaim ec : expenses) {
if (!"已报销".equals(ec.getStatus()) || !matchYear(ec.getExpenseDate(), resolvedYear)) continue;
if (!hit(ec.getDept(), b) && !hit(ec.getCompanySubject(), b)) continue;
String m = toMonth(ec.getExpenseDate());
if (m == null) continue;
expMap.merge(m, Money.nz(ec.getAmount()), Money::add);
}
for (int mo = 1; mo <= 12; mo++) {
String period = resolvedYear + "-" + String.format("%02d", mo);
BigDecimal inc = incMap.getOrDefault(period, BigDecimal.ZERO);
BigDecimal cost = costMap.getOrDefault(period, BigDecimal.ZERO);
BigDecimal exp = expMap.getOrDefault(period, BigDecimal.ZERO);
BigDecimal profit = Money.sub(Money.sub(inc, cost), exp);
result.add(new AllBranchMonthRow(b.getName(), period,
inc.doubleValue(), cost.doubleValue(),
exp.doubleValue(), profit.doubleValue()));
}
}
return ApiResp.ok(result);
}
// ============================================================
// 3. 指定区间聚合(period 格式 "yyyy-MM"startPeriod~endPeriod
// ============================================================
public record PeriodPnlResult(
String orgUnit, String startPeriod, String endPeriod,
double income, double cost, double expense, double profit) {
}
@GetMapping("/period")
public ApiResp<PeriodPnlResult> period(
@RequestParam String orgUnit,
@RequestParam String startPeriod,
@RequestParam String endPeriod) {
if (orgUnit == null || orgUnit.isBlank()) throw new ApiException(400, "orgUnit 不能为空");
if (startPeriod == null || startPeriod.length() < 7) throw new ApiException(400, "startPeriod 格式应为 yyyy-MM");
if (endPeriod == null || endPeriod.length() < 7) throw new ApiException(400, "endPeriod 格式应为 yyyy-MM");
OrgBranch branch = findBranch(orgUnit);
String effectiveOrgUnit = branch != null ? branch.getName() : orgUnit;
String effectiveCode = branch != null ? branch.getCode() : orgUnit;
BigDecimal income = BigDecimal.ZERO;
BigDecimal cost = BigDecimal.ZERO;
BigDecimal expense = BigDecimal.ZERO;
for (Contract ct : contractRepo.findAll()) {
String m = toMonth(ct.getSignDate());
if (m == null || m.compareTo(startPeriod) < 0 || m.compareTo(endPeriod) > 0) continue;
if (!hitBranch(ct.getCompanySubject(), effectiveOrgUnit, effectiveCode)) continue;
income = Money.add(income, Money.nz(ct.getPaidAmount()));
}
// 成本:独立归集优先
List<BranchCostEntry> entries = costEntryRepo.findByOrgUnit(effectiveOrgUnit);
if (entries.isEmpty()) entries = costEntryRepo.findByOrgUnit(effectiveCode);
if (!entries.isEmpty()) {
for (BranchCostEntry ce : entries) {
String p = ce.getCostPeriod();
if (p == null || p.length() < 7) continue;
String m = p.substring(0, 7);
if (m.compareTo(startPeriod) < 0 || m.compareTo(endPeriod) > 0) continue;
cost = Money.add(cost, Money.nz(ce.getCostAmount()));
}
} else {
for (Contract ct : contractRepo.findAll()) {
String m = toMonth(ct.getSignDate());
if (m == null || m.compareTo(startPeriod) < 0 || m.compareTo(endPeriod) > 0) continue;
if (!hitBranch(ct.getCompanySubject(), effectiveOrgUnit, effectiveCode)) continue;
cost = Money.add(cost, Money.nz(ct.getInvoicedAmount()));
}
}
for (ExpenseClaim ec : expenseRepo.findAll()) {
if (!"已报销".equals(ec.getStatus())) continue;
String m = toMonth(ec.getExpenseDate());
if (m == null || m.compareTo(startPeriod) < 0 || m.compareTo(endPeriod) > 0) continue;
if (!hitBranch(ec.getDept(), effectiveOrgUnit, effectiveCode)
&& !hitBranch(ec.getCompanySubject(), effectiveOrgUnit, effectiveCode)) continue;
expense = Money.add(expense, Money.nz(ec.getAmount()));
}
BigDecimal profit = Money.sub(Money.sub(income, cost), expense);
return ApiResp.ok(new PeriodPnlResult(effectiveOrgUnit, startPeriod, endPeriod,
income.doubleValue(), cost.doubleValue(), expense.doubleValue(), profit.doubleValue()));
}
// ============================================================
// helpers
// ============================================================
private OrgBranch findBranch(String orgUnit) {
for (OrgBranch b : orgBranchRepo.findAll()) {
if (orgUnit.equalsIgnoreCase(b.getName()) || orgUnit.equalsIgnoreCase(b.getCode())) {
return b;
}
}
return null;
}
private boolean hitBranch(String field, String name, String code) {
if (field == null || field.isBlank()) return false;
String f = field.trim();
return f.equalsIgnoreCase(name) || f.equalsIgnoreCase(code);
}
private boolean hit(String field, OrgBranch b) {
if (field == null || field.isBlank()) return false;
String f = field.trim();
return f.equalsIgnoreCase(safe(b.getName())) || f.equalsIgnoreCase(safe(b.getCode()));
}
private boolean hitEntry(String entryOrgUnit, OrgBranch b) {
if (entryOrgUnit == null || entryOrgUnit.isBlank()) return false;
String f = entryOrgUnit.trim();
return f.equalsIgnoreCase(safe(b.getName())) || f.equalsIgnoreCase(safe(b.getCode()));
}
private static String safe(String s) { return s == null ? "" : s.trim(); }
/** 取日期前 7 位 yyyy-MM;日期为空或太短返回 null。 */
private static String toMonth(String date) {
if (date == null || date.trim().length() < 7) return null;
return date.trim().substring(0, 7);
}
/** 判断日期前 4 位是否命中 year;year 为空恒命中。 */
private static boolean matchYear(String date, String year) {
if (year == null || year.isBlank()) return true;
if (date == null || date.trim().length() < 4) return false;
return year.equals(date.trim().substring(0, 4));
}
private static double r2(double v) { return Math.round(v * 100d) / 100d; }
}