恢复点(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>
299 lines
13 KiB
Java
299 lines
13 KiB
Java
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 持久化套路)。
|
||
* 执行结果写入 FinCostCalc(allocationDriver 填"多动因组合",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
|
||
) {}
|
||
|
||
/**
|
||
* 按规则对各成本对象执行多动因分摊,自动写入 FinCostCalc(manufacturingOverhead 为分摊额)。
|
||
*
|
||
* 分摊逻辑:
|
||
* 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);
|
||
}
|
||
}
|