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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,308 @@
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);
}
}