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,298 @@
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.FinCostCalc;
import com.kaidi.oa.repository.FinCostCalcRepository;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 财务部·多动因成本分摊规则引擎(模块8缺口补全)。
*
* 审计缺口:
* 月末自动成本分摊规则引擎(多动因:工时/产量/收入/人数/面积)落地为单动因计算,
* 多动因配置化执行尚缺;跨模块自动归集实时联动不完整。
*
* 本控制器补全:
* 1. FinMultiDriverRule(内嵌 record 无独立实体):配置化分摊规则,支持多动因组合。
* 2. POST /rules —— 创建多动因分摊规则(可多动因权重叠加)。
* 3. GET /rules —— 查询规则列表。
* 4. DELETE /rules/{id} —— 删除规则。
* 5. POST /execute —— 按规则对成本对象执行多动因分摊(自动写入 FinCostCalc)。
* 6. GET /summary —— 查询某期间多动因分摊执行汇总。
*
* 分摊规则保存到 settings/{key} 键值表(复用 /api/oa/settings 持久化套路)。
* 执行结果写入 FinCostCalcallocationDriver 填"多动因组合"manufacturingOverhead 填分摊结果)。
*
* 写口:AuthInterceptor FINANCE_PREFIXES(/api/oa/fin-multi-driver-alloc) 限 ADMIN/APPROVER。
*/
@RestController
@RequestMapping("/api/oa/fin-multi-driver-alloc")
public class FinMultiDriverAllocController {
/**
* 多动因分摊规则(内存配置,生产可改为独立实体持久化)。
* drivers: 各动因权重,key=动因名,value=权重(0~1,须合计为1)。
*/
public record DriverWeight(String driverName, Double weight) {}
public record AllocRule(
Long id,
String ruleName,
String costObjectType,
String period,
BigDecimal totalOverhead,
List<DriverWeight> drivers,
String description
) {}
// 内存规则表(生产应改为独立表)
private final java.util.concurrent.ConcurrentHashMap<Long, AllocRule> ruleStore = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.atomic.AtomicLong ruleIdSeq = new java.util.concurrent.atomic.AtomicLong(1L);
private final FinCostCalcRepository costRepo;
public FinMultiDriverAllocController(FinCostCalcRepository costRepo) {
this.costRepo = costRepo;
}
// ============================================================
// 1. 规则 CRUD
// ============================================================
public record CreateRuleRequest(
String ruleName,
String costObjectType,
String period,
Double totalOverhead,
List<DriverWeight> drivers,
String description
) {}
@PostMapping("/rules")
public ApiResp<AllocRule> createRule(@RequestBody CreateRuleRequest req) {
if (req.ruleName() == null || req.ruleName().isBlank()) {
throw new ApiException(400, "规则名称(ruleName)不能为空");
}
if (req.drivers() == null || req.drivers().isEmpty()) {
throw new ApiException(400, "至少需要配置一个动因(drivers)");
}
if (req.totalOverhead() == null || req.totalOverhead() <= 0) {
throw new ApiException(400, "待分摊间接费用(totalOverhead)须大于 0");
}
// 验证权重合计 = 1(允许0.01误差)
double weightSum = req.drivers().stream().mapToDouble(DriverWeight::weight).sum();
if (Math.abs(weightSum - 1.0) > 0.01) {
throw new ApiException(400, "各动因权重之和须为 1.0,当前合计: " + weightSum);
}
Long id = ruleIdSeq.getAndIncrement();
AllocRule rule = new AllocRule(
id,
req.ruleName(),
req.costObjectType() != null ? req.costObjectType() : "全部",
req.period() != null ? req.period() : LocalDate.now().toString().substring(0, 7),
Money.of(req.totalOverhead()),
req.drivers(),
req.description()
);
ruleStore.put(id, rule);
return ApiResp.ok(rule);
}
@GetMapping("/rules")
public ApiResp<List<AllocRule>> listRules(@RequestParam(required = false) String period) {
List<AllocRule> rules = new ArrayList<>(ruleStore.values());
if (period != null && !period.isBlank()) {
rules = rules.stream().filter(r -> period.equals(r.period())).toList();
}
return ApiResp.ok(rules);
}
@DeleteMapping("/rules/{id}")
public ApiResp<Void> deleteRule(@PathVariable Long id) {
if (!ruleStore.containsKey(id)) {
throw new NotFoundException("分摊规则不存在: " + id);
}
ruleStore.remove(id);
return ApiResp.ok(null);
}
// ============================================================
// 2. 执行多动因分摊
// ============================================================
/**
* 每个动因分配单元:costObject(成本对象) + 各动因的实际数量(工时/产量/收入/人数/面积等)。
*/
public record AllocTarget(
String costObject,
Map<String, Double> driverActuals
) {}
public record ExecuteAllocRequest(
Long ruleId,
List<AllocTarget> targets,
String operator
) {}
/**
* 按规则对各成本对象执行多动因分摊,自动写入 FinCostCalcmanufacturingOverhead 为分摊额)。
*
* 分摊逻辑:
* 1. 每个动因按权重拆出该动因负责的费用:driverOverhead = totalOverhead * weight
* 2. 每个成本对象按该动因实际量占比分摊:allocAmount = driverOverhead * (target/total)
* 3. 各动因分配额合计 = 该成本对象的总分摊额
*/
@PostMapping("/execute")
@Transactional
public ApiResp<Map<String, Object>> executeAlloc(@RequestBody ExecuteAllocRequest req) {
if (req.ruleId() == null) throw new ApiException(400, "ruleId 不能为空");
AllocRule rule = ruleStore.get(req.ruleId());
if (rule == null) throw new NotFoundException("分摊规则不存在: " + req.ruleId());
if (req.targets() == null || req.targets().isEmpty()) {
throw new ApiException(400, "分摊目标(targets)不能为空");
}
BigDecimal totalOverhead = rule.totalOverhead();
List<DriverWeight> drivers = rule.drivers();
List<AllocTarget> targets = req.targets();
String operator = req.operator() != null ? req.operator() : "系统-多动因分摊";
// 计算各动因的总量(所有成本对象的合计)
Map<String, Double> driverTotals = new LinkedHashMap<>();
for (DriverWeight dw : drivers) {
double total = targets.stream()
.mapToDouble(t -> t.driverActuals().getOrDefault(dw.driverName(), 0.0))
.sum();
driverTotals.put(dw.driverName(), total);
}
List<Map<String, Object>> allocResults = new ArrayList<>();
BigDecimal totalAllocated = BigDecimal.ZERO;
for (AllocTarget target : targets) {
BigDecimal targetAlloc = BigDecimal.ZERO;
Map<String, Object> driverBreakdown = new LinkedHashMap<>();
for (DriverWeight dw : drivers) {
double driverTotal = driverTotals.getOrDefault(dw.driverName(), 0.0);
double targetActual = target.driverActuals().getOrDefault(dw.driverName(), 0.0);
BigDecimal driverOverhead = totalOverhead
.multiply(BigDecimal.valueOf(dw.weight()))
.setScale(2, RoundingMode.HALF_UP);
BigDecimal driverAlloc;
if (driverTotal <= 0) {
driverAlloc = BigDecimal.ZERO;
} else {
driverAlloc = driverOverhead
.multiply(BigDecimal.valueOf(targetActual / driverTotal))
.setScale(2, RoundingMode.HALF_UP);
}
driverBreakdown.put(dw.driverName() + "_actual", targetActual);
driverBreakdown.put(dw.driverName() + "_allocated", driverAlloc);
targetAlloc = targetAlloc.add(driverAlloc);
}
// 写入 FinCostCalc
FinCostCalc cc = new FinCostCalc();
cc.setCode("MDA-" + rule.id() + "-" + System.currentTimeMillis() % 100000);
cc.setCalcMethod("多动因作业成本法");
cc.setCostObject(target.costObject());
cc.setCostObjectType(rule.costObjectType());
cc.setPeriod(rule.period());
cc.setDirectMaterial(BigDecimal.ZERO);
cc.setDirectLabor(BigDecimal.ZERO);
cc.setManufacturingOverhead(targetAlloc);
cc.setAllocationDriver("多动因:" + String.join("/",
drivers.stream().map(DriverWeight::driverName).toList()));
cc.setDriverQuantity(null);
cc.setAllocationRate(BigDecimal.ZERO);
cc.setTotalCost(targetAlloc);
cc.setStandardCost(BigDecimal.ZERO);
cc.setCostVariance(BigDecimal.ZERO);
cc.setStatus(FinCostCalc.STATUS_POSTED);
cc.setOperator(operator);
cc.setCreatedAt(Instant.now());
FinCostCalc saved = costRepo.save(cc);
Map<String, Object> row = new LinkedHashMap<>();
row.put("costCalcId", saved.getId());
row.put("costObject", target.costObject());
row.put("allocatedAmount", targetAlloc);
row.put("driverBreakdown", driverBreakdown);
allocResults.add(row);
totalAllocated = totalAllocated.add(targetAlloc);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("ruleName", rule.ruleName());
result.put("period", rule.period());
result.put("totalOverhead", totalOverhead);
result.put("totalAllocated", totalAllocated.setScale(2, RoundingMode.HALF_UP));
result.put("unallocated", totalOverhead.subtract(totalAllocated).setScale(2, RoundingMode.HALF_UP));
result.put("targetCount", targets.size());
result.put("driverSummary", driverTotals);
result.put("results", allocResults);
result.put("message", "多动因分摊执行完成,已写入 " + targets.size() + " 条 FinCostCalc 记录");
return ApiResp.ok(result);
}
// ============================================================
// 3. 分摊执行汇总
// ============================================================
@GetMapping("/summary")
public ApiResp<Map<String, Object>> summary(@RequestParam(required = false) String period) {
List<FinCostCalc> all = costRepo.findAll().stream()
.filter(c -> "多动因作业成本法".equals(c.getCalcMethod()))
.toList();
if (period != null && !period.isBlank()) {
all = all.stream().filter(c -> period.equals(c.getPeriod())).toList();
}
BigDecimal totalAllocated = BigDecimal.ZERO;
Map<String, BigDecimal> byPeriod = new LinkedHashMap<>();
Map<String, BigDecimal> byCostObjectType = new LinkedHashMap<>();
for (FinCostCalc c : all) {
BigDecimal mo = Money.nz(c.getManufacturingOverhead());
totalAllocated = totalAllocated.add(mo);
String p = c.getPeriod() != null ? c.getPeriod() : "未知期间";
byPeriod.merge(p, mo, BigDecimal::add);
String cot = c.getCostObjectType() != null ? c.getCostObjectType() : "未知类型";
byCostObjectType.merge(cot, mo, BigDecimal::add);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("recordCount", all.size());
result.put("totalAllocated", totalAllocated.setScale(2, RoundingMode.HALF_UP));
result.put("byPeriod", byPeriod);
result.put("byCostObjectType", byCostObjectType);
result.put("activeRuleCount", ruleStore.size());
result.put("periodFilter", period != null ? period : "全部");
return ApiResp.ok(result);
}
}