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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user