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):
*
* - 无在制品(WIP)月末成本端点——无约当产量法或实际投料法计算在制品成本的控制器;
* - 发货确认时无自动结转销售成本(销售成本与收入匹配缺联动);
* - ServiceTicket 售后成本归集按 customer 匹配项目而非 projectCode,精度不足。
*
*
* 本控制器为纯只读聚合(无新写入),跨模块聚合已有实体数据:
*
* - /wip/monthly WIP 月末成本报告:按工单状态(待生产/生产中/已完工未入库)筛出在制品工单,
* 汇总各工单领料成本(直接材料)+ 人工成本(报工),按约当完成度估算在制品成本;
* - /wip/cogs-transfer 发货结转明细:已发货(status=已移交)的 {@link MfgDelivery} 订单,
* 从 MaterialIssue + ProductionReport 自动拉取对应项目的归集成本,模拟「发货时结转销售成本」;
* - /wip/project-profit 项目利润模拟:对每个「已验收」项目,用合同额 - 归集成本估算毛利;
* - /wip/trend 成本趋势端点:按月聚合历史领料+人工成本,形成趋势折线图数据。
*
*
* 金额一律 {@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);
}
}