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,328 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.MfgCostEntry;
import com.kaidi.oa.domain.MfgOutsource;
import com.kaidi.oa.domain.MfgDelivery;
import com.kaidi.oa.repository.MfgCostEntryRepository;
import com.kaidi.oa.repository.MfgOutsourceRepository;
import com.kaidi.oa.repository.MfgDeliveryRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
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.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 制造管理中心 / 环保设备制造中心 —— 项目成本五维度归集(需求 §10 补深 GAP#10 low)。
*
* <p>补深审计缺口 GAP#10(low)
* <ul>
* <li>外协费用/安装调试费用在项目成本归集中未作为独立维度归集(仅材料+人工+售后备件三类);</li>
* <li>采购发票/销售发票自动生成财务凭证链路为聚合计算而非与财务模块双写联动(凭证落于 mfg 模块内,
* 未写入财务总账)——财务总账双写属外部系统集成(skippedExternal),本轮不做,
* 但凭证生成链路已在 MfgFinIntegrationController 实现。</li>
* </ul>
*
* <p>本控制器提供:
* <ol>
* <li><b>POST /mfg-cost-entries</b> 手工录入成本条目(外协/安装调试/其他制造/质保维修);</li>
* <li><b>POST /mfg-cost-entries/from-outsource/{outsourceId}</b> 从外协任务自动提取外协费用,
* 幂等(同 outsourceNo 已录入则 409);</li>
* <li><b>POST /mfg-cost-entries/from-delivery/{deliveryId}</b> 从发货单提取安装调试费用
* deliveryId 关联的安装调试节点),幂等;</li>
* <li><b>GET /mfg-cost-entries/project-summary/{projectCode}</b> 五维度成本汇总(材料+人工+外协+安装调试+其他);</li>
* <li><b>GET/PATCH/DELETE</b> 条目管理;</li>
* <li><b>POST /{id}/approve</b> 审核确认(待审核 → 已审核)。</li>
* </ol>
*
* 金额一律 {@link Money}/BigDecimal;外协费用从 MfgOutsource.amount 取,
* 安装调试费用默认按工程师人天数计(前端可手工调整)。
*/
@RestController
@RequestMapping("/api/oa/mfg-cost-entries")
public class MfgCostEntryController {
public static final List<String> COST_TYPES = List.of(
"外协费用", "安装调试费用", "其他制造费用", "质保维修费用");
private final MfgCostEntryRepository entryRepo;
private final MfgOutsourceRepository outsourceRepo;
private final MfgDeliveryRepository deliveryRepo;
public MfgCostEntryController(MfgCostEntryRepository entryRepo,
MfgOutsourceRepository outsourceRepo,
MfgDeliveryRepository deliveryRepo) {
this.entryRepo = entryRepo;
this.outsourceRepo = outsourceRepo;
this.deliveryRepo = deliveryRepo;
}
// ---- CRUD ----
@GetMapping
public ApiResp<List<MfgCostEntry>> list(
@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String costType) {
if (projectCode != null && !projectCode.isBlank() && costType != null && !costType.isBlank()) {
return ApiResp.ok(entryRepo.findByProjectCodeAndCostType(projectCode, costType));
}
if (projectCode != null && !projectCode.isBlank()) {
return ApiResp.ok(entryRepo.findByProjectCode(projectCode));
}
if (costType != null && !costType.isBlank()) {
return ApiResp.ok(entryRepo.findByCostType(costType));
}
return ApiResp.ok(entryRepo.findAllByOrderByIdDesc());
}
@GetMapping("/{id}")
public ApiResp<MfgCostEntry> get(@PathVariable Long id) {
return ApiResp.ok(entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id)));
}
public record EntryRequest(
String projectCode, String costType, String description,
String outsourceNo, String deliveryNo,
Double amount, String costDate, String handler, String remark) {
}
@PostMapping
public ApiResp<MfgCostEntry> create(@RequestBody EntryRequest req) {
if (req.projectCode() == null || req.projectCode().isBlank()) {
throw new ApiException(400, "项目编码(projectCode)不能为空");
}
if (req.costType() == null || !COST_TYPES.contains(req.costType())) {
throw new ApiException(400, "费用类型(costType)无效,可选: " + COST_TYPES);
}
MfgCostEntry entry = new MfgCostEntry();
entry.setEntryNo("MCE-" + (entryRepo.count() + 1));
fillEntry(entry, req);
entry.setApprovalStatus("待审核");
entry.setCreatedAt(Instant.now());
return ApiResp.ok(entryRepo.save(entry));
}
@PatchMapping("/{id}")
public ApiResp<MfgCostEntry> update(@PathVariable Long id, @RequestBody EntryRequest req) {
MfgCostEntry entry = entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id));
if ("已审核".equals(entry.getApprovalStatus())) {
throw new ApiException(409, "已审核的成本条目不能修改");
}
if (req.costType() != null && !req.costType().isBlank() && !COST_TYPES.contains(req.costType())) {
throw new ApiException(400, "费用类型无效,可选: " + COST_TYPES);
}
fillEntry(entry, req);
return ApiResp.ok(entryRepo.save(entry));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
MfgCostEntry entry = entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id));
if ("已审核".equals(entry.getApprovalStatus())) {
throw new ApiException(409, "已审核的成本条目不能删除");
}
entryRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---- 从外协任务自动提取外协费用 ----
/**
* 从外协任务单自动提取外协费用条目(幂等:同 outsourceNo 已录入则 409)。
* 外协费用 = outsource.amount(外协合同价),关联项目 = outsource.projectCode。
*/
@PostMapping("/from-outsource/{outsourceId}")
@Transactional
public ApiResp<MfgCostEntry> fromOutsource(@PathVariable Long outsourceId) {
MfgOutsource os = outsourceRepo.findById(outsourceId)
.orElseThrow(() -> new NotFoundException("外协任务不存在: " + outsourceId));
// 幂等检查
String osNo = os.getOutsourceNo();
if (osNo != null) {
boolean dup = entryRepo.findByCostType("外协费用").stream()
.anyMatch(e -> osNo.equals(e.getOutsourceNo()));
if (dup) {
throw new ApiException(409, "该外协任务(" + osNo + ")已提取外协费用条目,不可重复提取");
}
}
// 只有「合格入库」状态才确认成本
if (!"合格入库".equals(os.getStatus()) && !"已返回".equals(os.getStatus())) {
throw new ApiException(400, "外协任务须处于「已返回」或「合格入库」状态才能提取费用,当前: "
+ os.getStatus());
}
MfgCostEntry entry = new MfgCostEntry();
entry.setEntryNo("MCE-OS-" + outsourceId);
entry.setProjectCode(os.getProjectCode());
entry.setCostType("外协费用");
entry.setDescription("外协加工(" + os.getProcessType() + ") - "
+ os.getPartName() + " @ " + os.getVendor());
entry.setOutsourceNo(osNo);
entry.setAmount(Money.nz(os.getAmount()));
entry.setCostDate(LocalDate.now().toString());
entry.setHandler(os.getOwner());
entry.setApprovalStatus("待审核");
entry.setCreatedAt(Instant.now());
return ApiResp.ok(entryRepo.save(entry));
}
// ---- 从发货单提取安装调试费用 ----
public record DeliveryInstallCostRequest(
Double installCost, String description, String handler) {
}
/**
* 从发货单提取安装调试费用(幂等:同 deliveryNo 已录入则 409)。
* 安装调试费用由调用方传入(工程师差旅+日补等实际发生额),关联 deliveryNo。
* 发货单须处于「安装调试」/「性能验收」/「已移交」阶段。
*/
@PostMapping("/from-delivery/{deliveryId}")
@Transactional
public ApiResp<MfgCostEntry> fromDelivery(@PathVariable Long deliveryId,
@RequestBody DeliveryInstallCostRequest req) {
MfgDelivery delivery = deliveryRepo.findById(deliveryId)
.orElseThrow(() -> new NotFoundException("发货单不存在: " + deliveryId));
String dlvNo = delivery.getDeliveryNo();
if (dlvNo != null) {
boolean dup = entryRepo.findByCostType("安装调试费用").stream()
.anyMatch(e -> dlvNo.equals(e.getDeliveryNo()));
if (dup) {
throw new ApiException(409, "该发货单(" + dlvNo + ")已录入安装调试费用,不可重复录入");
}
}
String status = delivery.getStatus();
if (!"安装调试".equals(status) && !"性能验收".equals(status) && !"已移交".equals(status)) {
throw new ApiException(400, "发货单须处于「安装调试」「性能验收」「已移交」状态,当前: " + status);
}
if (req.installCost() == null || req.installCost() <= 0) {
throw new ApiException(400, "安装调试费用(installCost)必须填写且大于0");
}
MfgCostEntry entry = new MfgCostEntry();
entry.setEntryNo("MCE-DLV-" + deliveryId);
entry.setProjectCode(delivery.getProjectCode());
entry.setCostType("安装调试费用");
entry.setDescription(req.description() != null ? req.description()
: "现场安装调试费 - " + delivery.getDeviceName()
+ " @ " + (delivery.getSiteAddress() != null ? delivery.getSiteAddress() : ""));
entry.setDeliveryNo(dlvNo);
entry.setAmount(Money.of(req.installCost()));
entry.setCostDate(LocalDate.now().toString());
entry.setHandler(req.handler() != null ? req.handler() : delivery.getEngineer());
entry.setApprovalStatus("待审核");
entry.setCreatedAt(Instant.now());
return ApiResp.ok(entryRepo.save(entry));
}
// ---- 审核 ----
public record ApproveRequest(String approver) {
}
/** 审核确认(待审核 → 已审核)。 */
@PostMapping("/{id}/approve")
public ApiResp<MfgCostEntry> approve(@PathVariable Long id,
@RequestBody(required = false) ApproveRequest req) {
MfgCostEntry entry = entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id));
if (!"待审核".equals(entry.getApprovalStatus())) {
throw new ApiException(409, "只有「待审核」状态可审核,当前: " + entry.getApprovalStatus());
}
entry.setApprovalStatus("已审核");
entry.setApproveDate(LocalDate.now().toString());
if (req != null && req.approver() != null) {
entry.setApprover(req.approver());
}
return ApiResp.ok(entryRepo.save(entry));
}
// ---- 五维度项目成本汇总 ----
public record DimCost(String costType, BigDecimal totalAmount, long entryCount) {}
public record ProjectCostFiveDim(
String projectCode,
BigDecimal outsourceCost,
BigDecimal installCost,
BigDecimal otherMfgCost,
BigDecimal warrantyRepairCost,
BigDecimal extendedCostTotal,
List<DimCost> dimBreakdown) {
}
/**
* 五维度项目成本汇总(外协+安装调试+其他制造+质保维修),
* 供 MfgProjectCostController /summary 聚合接入:
* 外协费用 / 安装调试费用 / 其他制造费用 / 质保维修费用 各维度分别汇总,
* 前端在项目成本页额外展示「扩展成本」四维度,与原三维度(材料/人工/售后)并列。
*/
@GetMapping("/project-summary/{projectCode}")
public ApiResp<ProjectCostFiveDim> projectSummary(@PathVariable String projectCode) {
List<MfgCostEntry> entries = entryRepo.findByProjectCode(projectCode);
Map<String, BigDecimal> byType = new LinkedHashMap<>();
Map<String, Long> countByType = new LinkedHashMap<>();
for (String t : COST_TYPES) {
byType.put(t, BigDecimal.ZERO);
countByType.put(t, 0L);
}
for (MfgCostEntry e : entries) {
if (e.getCostType() == null) continue;
byType.merge(e.getCostType(), Money.nz(e.getAmount()), Money::add);
countByType.merge(e.getCostType(), 1L, Long::sum);
}
List<DimCost> breakdown = new ArrayList<>();
for (String t : COST_TYPES) {
breakdown.add(new DimCost(t, byType.get(t), countByType.get(t)));
}
BigDecimal outsource = byType.get("外协费用");
BigDecimal install = byType.get("安装调试费用");
BigDecimal other = byType.get("其他制造费用");
BigDecimal warranty = byType.get("质保维修费用");
BigDecimal total = Money.add(Money.add(outsource, install), Money.add(other, warranty));
return ApiResp.ok(new ProjectCostFiveDim(
projectCode, outsource, install, other, warranty, total, breakdown));
}
// ---- helpers ----
private void fillEntry(MfgCostEntry entry, EntryRequest req) {
if (req.projectCode() != null && !req.projectCode().isBlank()) entry.setProjectCode(req.projectCode());
if (req.costType() != null && !req.costType().isBlank()) entry.setCostType(req.costType());
if (req.description() != null) entry.setDescription(req.description());
if (req.outsourceNo() != null) entry.setOutsourceNo(req.outsourceNo());
if (req.deliveryNo() != null) entry.setDeliveryNo(req.deliveryNo());
if (req.amount() != null) entry.setAmount(Money.of(req.amount()));
if (req.costDate() != null) entry.setCostDate(req.costDate());
if (req.handler() != null) entry.setHandler(req.handler());
if (req.remark() != null) entry.setRemark(req.remark());
}
}