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.ProductionReport; import com.kaidi.oa.domain.ServiceTicket; import com.kaidi.oa.domain.StandardCost; import com.kaidi.oa.repository.MaterialIssueRepository; import com.kaidi.oa.repository.ProductionReportRepository; import com.kaidi.oa.repository.ServiceTicketRepository; import com.kaidi.oa.repository.StandardCostRepository; 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.Locale; import java.util.Map; /** * 制造管理中心·项目成本归集(成本核算与财务集成,需求 §10)。无新建实体的跨模块只读聚合: * 按项目维度把分散在各执行单据里的成本自动归集,而非人工汇总: * * 实际成本 = 直接材料 + 直接人工 + 售后成本;与标准成本({@link StandardCost} 按产品名匹配)对比给量差/价差口径的 * 差异 variance(实际 − 标准)与差异率,指导报价与成本控制(需求 §10 标准成本与实际成本对比)。 * * 该控制器读侧暴露成本金额,已登记进 SENSITIVE_READ_PREFIXES(与 standard-costs / material-issues 同档)。 * 金额一律 {@link Money}/BigDecimal,杜绝裸 double 算钱。 */ @RestController @RequestMapping("/api/oa/mfg-project-costs") public class MfgProjectCostController { private final MaterialIssueRepository issueRepo; private final ProductionReportRepository reportRepo; private final ServiceTicketRepository ticketRepo; private final StandardCostRepository standardCostRepo; public MfgProjectCostController(MaterialIssueRepository issueRepo, ProductionReportRepository reportRepo, ServiceTicketRepository ticketRepo, StandardCostRepository standardCostRepo) { this.issueRepo = issueRepo; this.reportRepo = reportRepo; this.ticketRepo = ticketRepo; this.standardCostRepo = standardCostRepo; } public record ProjectCostRow(String project, double materialCost, double laborCost, double serviceCost, double actualCost, double standardCost, double variance, double varianceRate, String varianceFlag) { } public record CostSummary(List rows, double totalMaterial, double totalLabor, double totalService, double totalActual, double totalStandard, double totalVariance) { } private static final class Acc { BigDecimal material = BigDecimal.ZERO; BigDecimal labor = BigDecimal.ZERO; BigDecimal service = BigDecimal.ZERO; } /** * 按项目归集成本并与标准成本对比。可选 project 过滤(留空=全部项目)。 * 标准成本按项目名 = 标准成本产品名匹配(含包含匹配)取一条作为基准。 */ @GetMapping("/summary") public ApiResp summary(@RequestParam(required = false) String project) { String filter = project == null || project.isBlank() ? null : norm(project); Map byProject = new LinkedHashMap<>(); for (MaterialIssue mi : issueRepo.findAll()) { String p = mi.getProjectName(); if (p == null || p.isBlank() || "已退".equals(mi.getStatus())) { continue; } if (filter != null && !norm(p).equals(filter)) { continue; } byProject.computeIfAbsent(p, k -> new Acc()).material = Money.add(byProject.get(p).material, mi.getAmount()); } for (ProductionReport r : reportRepo.findAll()) { String p = r.getProjectName(); if (p == null || p.isBlank() || "已冲销".equals(r.getStatus())) { continue; } if (filter != null && !norm(p).equals(filter)) { continue; } byProject.computeIfAbsent(p, k -> new Acc()).labor = Money.add(byProject.get(p).labor, r.getLaborCost()); } for (ServiceTicket t : ticketRepo.findAll()) { String p = t.getCustomer(); if (p == null || p.isBlank()) { continue; } if (filter != null && !norm(p).equals(filter)) { continue; } byProject.computeIfAbsent(p, k -> new Acc()).service = Money.add(byProject.get(p).service, t.getPartsCost()); } // 标准成本索引(按产品名规范化)。 Map stdByName = new LinkedHashMap<>(); for (StandardCost sc : standardCostRepo.findAll()) { if (sc.getProductName() != null && !sc.getProductName().isBlank()) { stdByName.putIfAbsent(norm(sc.getProductName()), Money.nz(sc.getTotalStandard())); } } List rows = new ArrayList<>(); BigDecimal tMat = BigDecimal.ZERO, tLab = BigDecimal.ZERO, tSvc = BigDecimal.ZERO; BigDecimal tActual = BigDecimal.ZERO, tStd = BigDecimal.ZERO, tVar = BigDecimal.ZERO; for (Map.Entry e : byProject.entrySet()) { Acc a = e.getValue(); BigDecimal actual = Money.add(Money.add(a.material, a.labor), a.service); BigDecimal std = matchStandard(stdByName, e.getKey()); BigDecimal variance = Money.sub(actual, std); double varianceRate = std.signum() == 0 ? 0 : variance.divide(std, 4, RoundingMode.HALF_UP).doubleValue() * 100.0; String flag = variance.signum() > 0 ? "超支" : variance.signum() < 0 ? "节约" : "持平"; rows.add(new ProjectCostRow(e.getKey(), a.material.doubleValue(), a.labor.doubleValue(), a.service.doubleValue(), actual.doubleValue(), std.doubleValue(), variance.doubleValue(), Math.round(varianceRate * 100.0) / 100.0, flag)); tMat = Money.add(tMat, a.material); tLab = Money.add(tLab, a.labor); tSvc = Money.add(tSvc, a.service); tActual = Money.add(tActual, actual); tStd = Money.add(tStd, std); tVar = Money.add(tVar, variance); } return ApiResp.ok(new CostSummary(rows, tMat.doubleValue(), tLab.doubleValue(), tSvc.doubleValue(), tActual.doubleValue(), tStd.doubleValue(), tVar.doubleValue())); } /** 标准成本匹配:先精确名匹配,再退化为包含匹配(项目名常含产品名)。 */ private static BigDecimal matchStandard(Map stdByName, String project) { String key = norm(project); BigDecimal exact = stdByName.get(key); if (exact != null) { return exact; } for (Map.Entry e : stdByName.entrySet()) { if (key.contains(e.getKey()) || e.getKey().contains(key)) { return e.getValue(); } } return BigDecimal.ZERO; } private static String norm(String s) { return s == null ? "" : s.trim().toLowerCase(Locale.ROOT); } }