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 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 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 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 costByMonth = new TreeMap<>(); Map costFromEntryFlag = new LinkedHashMap<>(); List 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 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 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> monthlyAll( @RequestParam(required = false, defaultValue = "") String year) { String resolvedYear = (year == null || year.isBlank()) ? String.valueOf(java.time.LocalDate.now().getYear()) : year; List branches = orgBranchRepo.findAll(); List contracts = contractRepo.findAll(); List expenses = expenseRepo.findAll(); List costEntries = costEntryRepo.findByYear(resolvedYear); List result = new ArrayList<>(); for (OrgBranch b : branches) { Map incMap = new TreeMap<>(); Map costMap = new TreeMap<>(); Map 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 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 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 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; } }