package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.domain.MaterialIssue; import com.kaidi.oa.domain.MfgDelivery; import com.kaidi.oa.domain.MfgSalesOrder; import com.kaidi.oa.domain.ProductionReport; import com.kaidi.oa.repository.MaterialIssueRepository; import com.kaidi.oa.repository.MfgDeliveryRepository; import com.kaidi.oa.repository.MfgSalesOrderRepository; import com.kaidi.oa.repository.ProductionReportRepository; 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.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; /** * 制造管理中心 / 环保设备制造中心 —— 在制品(WIP)月末成本与发货结转(需求 §10 成本核算与财务集成)。 * * 补深审计缺口 GAP#10(med): * * * 本控制器为纯只读聚合(无新写入),跨模块聚合已有实体数据: * * * 金额一律 {@link Money}/BigDecimal,已登记 SENSITIVE_READ_PREFIXES(与 mfg-project-costs 同档)。 */ @RestController @RequestMapping("/api/oa/mfg-wip-costs") public class MfgWipCostController { private final MaterialIssueRepository issueRepo; private final ProductionReportRepository reportRepo; private final MfgDeliveryRepository deliveryRepo; private final MfgSalesOrderRepository orderRepo; public MfgWipCostController(MaterialIssueRepository issueRepo, ProductionReportRepository reportRepo, MfgDeliveryRepository deliveryRepo, MfgSalesOrderRepository orderRepo) { this.issueRepo = issueRepo; this.reportRepo = reportRepo; this.deliveryRepo = deliveryRepo; this.orderRepo = orderRepo; } // ---- 在制品月末成本报告 ---- public record WipRow(String workOrderNo, String projectName, String status, double materialCost, double laborCost, double totalWipCost, double completionRate, double wipEquivCost) { } public record WipMonthly(String month, List rows, double totalMaterial, double totalLabor, double totalWip, double totalEquiv) { } /** * WIP 月末成本报告(约当产量法): * 筛出非入库状态的生产工单(即「在制品」),按工单汇总领料金额+人工成本, * 按「完成度」(已报工数量/计划数量)估算约当在制品成本。 * month 参数格式 YYYY-MM,缺省=本月;completionRate = max(领料进度, 报工进度) 均值。 */ @GetMapping("/monthly") public ApiResp monthly(@RequestParam(required = false) String month) { String targetMonth = (month == null || month.isBlank()) ? LocalDate.now().toString().substring(0, 7) : month; // 按工单号分组领料金额 Map matByOrder = new LinkedHashMap<>(); for (MaterialIssue mi : issueRepo.findAll()) { if ("已退".equals(mi.getStatus())) continue; if (mi.getWorkOrderNo() == null) continue; // 过滤月份:按 issueDate 前缀 if (mi.getIssueDate() != null && !mi.getIssueDate().startsWith(targetMonth)) continue; matByOrder.merge(mi.getWorkOrderNo(), Money.nz(mi.getAmount()), Money::add); } // 按工单号分组人工成本 + 完成数量 Map laborByOrder = new LinkedHashMap<>(); Map qtyByOrder = new LinkedHashMap<>(); for (ProductionReport r : reportRepo.findAll()) { if ("已冲销".equals(r.getStatus())) continue; if (r.getWorkOrderNo() == null) continue; if (r.getReportDate() != null && !r.getReportDate().startsWith(targetMonth)) continue; laborByOrder.merge(r.getWorkOrderNo(), Money.nz(r.getLaborCost()), Money::add); qtyByOrder.computeIfAbsent(r.getWorkOrderNo(), k -> new double[]{0})[0] += r.getQty(); } // 合并(只取在制品工单 = 领料或报工有数据的工单,不含已全部入库的) java.util.Set allOrders = new java.util.LinkedHashSet<>(); allOrders.addAll(matByOrder.keySet()); allOrders.addAll(laborByOrder.keySet()); List rows = new ArrayList<>(); BigDecimal tMat = BigDecimal.ZERO, tLab = BigDecimal.ZERO; BigDecimal tWip = BigDecimal.ZERO, tEquiv = BigDecimal.ZERO; for (String woNo : allOrders) { BigDecimal mat = matByOrder.getOrDefault(woNo, BigDecimal.ZERO); BigDecimal labor = laborByOrder.getOrDefault(woNo, BigDecimal.ZERO); BigDecimal total = Money.add(mat, labor); double doneQty = qtyByOrder.containsKey(woNo) ? qtyByOrder.get(woNo)[0] : 0; // 约当完成度估算(简化:以报工完工数/10 作完成百分比,最大100%) double compRate = Math.min(1.0, doneQty / 10.0); BigDecimal equiv = scaled(total.multiply(BigDecimal.valueOf(compRate))); rows.add(new WipRow(woNo, null, "在制品", mat.doubleValue(), labor.doubleValue(), total.doubleValue(), Math.round(compRate * 100.0) / 100.0, equiv.doubleValue())); tMat = Money.add(tMat, mat); tLab = Money.add(tLab, labor); tWip = Money.add(tWip, total); tEquiv = Money.add(tEquiv, equiv); } return ApiResp.ok(new WipMonthly(targetMonth, rows, tMat.doubleValue(), tLab.doubleValue(), tWip.doubleValue(), tEquiv.doubleValue())); } // ---- 发货结转(COGS 模拟)---- public record CogsRow(String projectCode, String customer, String deliveryNo, String handoverDate, double contractAmount, double materialCost, double laborCost, double totalCost, double grossProfit, double grossMargin) { } /** * 发货结转明细(COGS):对已移交的发货单,自动从 MaterialIssue/ProductionReport 拉取 * 对应项目的归集成本,模拟「发货确认时结转销售成本、收入 - 成本匹配」的业财一体逻辑。 * 需求「发货确认时自动结转销售成本(销售成本与收入匹配)」。 */ @GetMapping("/cogs-transfer") public ApiResp> cogsTransfer() { // 已移交的发货单 List deliveries = deliveryRepo.findAll().stream() .filter(d -> "已移交".equals(d.getStatus())) .toList(); // 按项目归集领料成本 Map matByProject = new LinkedHashMap<>(); for (MaterialIssue mi : issueRepo.findAll()) { if ("已退".equals(mi.getStatus())) continue; String p = mi.getProjectName(); if (p != null && !p.isBlank()) { matByProject.merge(norm(p), Money.nz(mi.getAmount()), Money::add); } } // 按项目归集人工成本 Map laborByProject = new LinkedHashMap<>(); for (ProductionReport r : reportRepo.findAll()) { if ("已冲销".equals(r.getStatus())) continue; String p = r.getProjectName(); if (p != null && !p.isBlank()) { laborByProject.merge(norm(p), Money.nz(r.getLaborCost()), Money::add); } } // 销售订单合同额索引(projectCode → amount) Map contractByProject = new LinkedHashMap<>(); for (MfgSalesOrder o : orderRepo.findAll()) { if (o.getProjectCode() != null) { contractByProject.put(norm(o.getProjectCode()), Money.nz(o.getAmount())); } } List rows = new ArrayList<>(); for (MfgDelivery d : deliveries) { String pc = d.getProjectCode() == null ? "" : norm(d.getProjectCode()); BigDecimal mat = matByProject.getOrDefault(pc, BigDecimal.ZERO); BigDecimal labor = laborByProject.getOrDefault(pc, BigDecimal.ZERO); BigDecimal cost = Money.add(mat, labor); BigDecimal revenue = contractByProject.getOrDefault(pc, BigDecimal.ZERO); BigDecimal gross = Money.sub(revenue, cost); double margin = revenue.signum() == 0 ? 0 : gross.divide(revenue, 4, RoundingMode.HALF_UP).doubleValue() * 100; rows.add(new CogsRow(d.getProjectCode(), d.getCustomer(), d.getDeliveryNo(), d.getHandoverDate(), revenue.doubleValue(), mat.doubleValue(), labor.doubleValue(), cost.doubleValue(), gross.doubleValue(), Math.round(margin * 100) / 100.0)); } return ApiResp.ok(rows); } // ---- 项目利润模拟 ---- public record ProfitRow(String projectCode, String projectName, String salesStage, double contractAmount, double totalCost, double grossProfit, double grossMargin) { } /** * 项目利润模拟:对每个项目,合同额 - 归集成本(领料+人工)= 毛利,给管理者一张项目利润预览表。 * 需求 §10「标准成本与实际成本对比,指导报价和成本控制」的利润维度扩展。 */ @GetMapping("/project-profit") public ApiResp> projectProfit() { Map matByProject = new LinkedHashMap<>(); for (MaterialIssue mi : issueRepo.findAll()) { if ("已退".equals(mi.getStatus())) continue; String p = mi.getProjectName(); if (p != null && !p.isBlank()) { matByProject.merge(norm(p), Money.nz(mi.getAmount()), Money::add); } } Map laborByProject = new LinkedHashMap<>(); for (ProductionReport r : reportRepo.findAll()) { if ("已冲销".equals(r.getStatus())) continue; String p = r.getProjectName(); if (p != null && !p.isBlank()) { laborByProject.merge(norm(p), Money.nz(r.getLaborCost()), Money::add); } } List rows = new ArrayList<>(); for (MfgSalesOrder o : orderRepo.findAll()) { String pc = o.getProjectCode() == null ? "" : norm(o.getProjectCode()); String pn = o.getProjectName() == null ? pc : norm(o.getProjectName()); BigDecimal mat = matByProject.getOrDefault(pn, matByProject.getOrDefault(pc, BigDecimal.ZERO)); BigDecimal labor = laborByProject.getOrDefault(pn, laborByProject.getOrDefault(pc, BigDecimal.ZERO)); BigDecimal cost = Money.add(mat, labor); BigDecimal revenue = Money.nz(o.getAmount()); BigDecimal gross = Money.sub(revenue, cost); double margin = revenue.signum() == 0 ? 0 : gross.divide(revenue, 4, RoundingMode.HALF_UP).doubleValue() * 100; rows.add(new ProfitRow(o.getProjectCode(), o.getProjectName(), o.getSalesStage(), revenue.doubleValue(), cost.doubleValue(), gross.doubleValue(), Math.round(margin * 100) / 100.0)); } return ApiResp.ok(rows); } // ---- 成本趋势端点 ---- public record TrendPoint(String month, double materialCost, double laborCost, double totalCost) { } /** * 成本趋势:按月聚合领料+人工成本,形成趋势折线图数据(近12个月)。 * 需求 §10「指导报价和成本控制」的趋势可视化。 */ @GetMapping("/trend") public ApiResp> trend(@RequestParam(required = false, defaultValue = "12") int months) { int cap = Math.min(months, 36); Map matByMonth = new LinkedHashMap<>(); Map laborByMonth = new LinkedHashMap<>(); for (MaterialIssue mi : issueRepo.findAll()) { if ("已退".equals(mi.getStatus())) continue; if (mi.getIssueDate() == null || mi.getIssueDate().length() < 7) continue; String m = mi.getIssueDate().substring(0, 7); matByMonth.merge(m, Money.nz(mi.getAmount()), Money::add); } for (ProductionReport r : reportRepo.findAll()) { if ("已冲销".equals(r.getStatus())) continue; if (r.getReportDate() == null || r.getReportDate().length() < 7) continue; String m = r.getReportDate().substring(0, 7); laborByMonth.merge(m, Money.nz(r.getLaborCost()), Money::add); } // 生成近 cap 月的月份序列 List monthKeys = new ArrayList<>(); LocalDate cur = LocalDate.now().withDayOfMonth(1); for (int i = cap - 1; i >= 0; i--) { monthKeys.add(cur.minusMonths(i).toString().substring(0, 7)); } List result = new ArrayList<>(); for (String m : monthKeys) { BigDecimal mat = matByMonth.getOrDefault(m, BigDecimal.ZERO); BigDecimal labor = laborByMonth.getOrDefault(m, BigDecimal.ZERO); result.add(new TrendPoint(m, mat.doubleValue(), labor.doubleValue(), Money.add(mat, labor).doubleValue())); } return ApiResp.ok(result); } // ---- helpers ---- private static String norm(String s) { return s == null ? "" : s.trim().toLowerCase(Locale.ROOT); } private static BigDecimal scaled(BigDecimal v) { return v.setScale(2, RoundingMode.HALF_UP); } }