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 MaterialIssue},已退冲销不计)金额合计;
* - 直接人工 = 该项目全部车间报工({@link ProductionReport},已冲销不计)人工成本合计;
* - 售后成本 = 该项目客户名命中的售后服务工单({@link ServiceTicket})备件成本合计(运维成本视角)。
*
* 实际成本 = 直接材料 + 直接人工 + 售后成本;与标准成本({@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);
}
}