Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FinMultiDriverAllocController.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

299 lines
13 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.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);
}
}