Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/BranchMonthlyPnlController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

379 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
}