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,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 归月;
|
||||
* 费用口径:ExpenseClaim(status=已报销)按 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,按 costPeriod(yyyy-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; }
|
||||
}
|
||||
Reference in New Issue
Block a user