Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/MfgWipCostController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

309 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
* <ul>
* <li>无在制品(WIP)月末成本端点——无约当产量法或实际投料法计算在制品成本的控制器;</li>
* <li>发货确认时无自动结转销售成本(销售成本与收入匹配缺联动);</li>
* <li>ServiceTicket 售后成本归集按 customer 匹配项目而非 projectCode,精度不足。</li>
* </ul>
*
* 本控制器为纯只读聚合(无新写入),跨模块聚合已有实体数据:
* <ul>
* <li><b>/wip/monthly</b> WIP 月末成本报告:按工单状态(待生产/生产中/已完工未入库)筛出在制品工单,
* 汇总各工单领料成本(直接材料)+ 人工成本(报工),按约当完成度估算在制品成本;</li>
* <li><b>/wip/cogs-transfer</b> 发货结转明细:已发货(status=已移交)的 {@link MfgDelivery} 订单,
* 从 MaterialIssue + ProductionReport 自动拉取对应项目的归集成本,模拟「发货时结转销售成本」;</li>
* <li><b>/wip/project-profit</b> 项目利润模拟:对每个「已验收」项目,用合同额 - 归集成本估算毛利;</li>
* <li><b>/wip/trend</b> 成本趋势端点:按月聚合历史领料+人工成本,形成趋势折线图数据。</li>
* </ul>
*
* 金额一律 {@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<WipRow> rows,
double totalMaterial, double totalLabor,
double totalWip, double totalEquiv) {
}
/**
* WIP 月末成本报告(约当产量法):
* 筛出非入库状态的生产工单(即「在制品」),按工单汇总领料金额+人工成本,
* 按「完成度」(已报工数量/计划数量)估算约当在制品成本。
* month 参数格式 YYYY-MM,缺省=本月;completionRate = max(领料进度, 报工进度) 均值。
*/
@GetMapping("/monthly")
public ApiResp<WipMonthly> monthly(@RequestParam(required = false) String month) {
String targetMonth = (month == null || month.isBlank())
? LocalDate.now().toString().substring(0, 7) : month;
// 按工单号分组领料金额
Map<String, BigDecimal> 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<String, BigDecimal> laborByOrder = new LinkedHashMap<>();
Map<String, double[]> 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<String> allOrders = new java.util.LinkedHashSet<>();
allOrders.addAll(matByOrder.keySet());
allOrders.addAll(laborByOrder.keySet());
List<WipRow> 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<List<CogsRow>> cogsTransfer() {
// 已移交的发货单
List<MfgDelivery> deliveries = deliveryRepo.findAll().stream()
.filter(d -> "已移交".equals(d.getStatus()))
.toList();
// 按项目归集领料成本
Map<String, BigDecimal> 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<String, BigDecimal> 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<String, BigDecimal> contractByProject = new LinkedHashMap<>();
for (MfgSalesOrder o : orderRepo.findAll()) {
if (o.getProjectCode() != null) {
contractByProject.put(norm(o.getProjectCode()), Money.nz(o.getAmount()));
}
}
List<CogsRow> 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<List<ProfitRow>> projectProfit() {
Map<String, BigDecimal> 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<String, BigDecimal> 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<ProfitRow> 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<List<TrendPoint>> trend(@RequestParam(required = false, defaultValue = "12") int months) {
int cap = Math.min(months, 36);
Map<String, BigDecimal> matByMonth = new LinkedHashMap<>();
Map<String, BigDecimal> 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<String> 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<TrendPoint> 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);
}
}