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 drivers, String description ) {} // 内存规则表(生产应改为独立表) private final java.util.concurrent.ConcurrentHashMap 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 drivers, String description ) {} @PostMapping("/rules") public ApiResp 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> listRules(@RequestParam(required = false) String period) { List 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 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 driverActuals ) {} public record ExecuteAllocRequest( Long ruleId, List targets, String operator ) {} /** * 按规则对各成本对象执行多动因分摊,自动写入 FinCostCalc(manufacturingOverhead 为分摊额)。 * * 分摊逻辑: * 1. 每个动因按权重拆出该动因负责的费用:driverOverhead = totalOverhead * weight * 2. 每个成本对象按该动因实际量占比分摊:allocAmount = driverOverhead * (target/total) * 3. 各动因分配额合计 = 该成本对象的总分摊额 */ @PostMapping("/execute") @Transactional public ApiResp> 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 drivers = rule.drivers(); List targets = req.targets(); String operator = req.operator() != null ? req.operator() : "系统-多动因分摊"; // 计算各动因的总量(所有成本对象的合计) Map 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> allocResults = new ArrayList<>(); BigDecimal totalAllocated = BigDecimal.ZERO; for (AllocTarget target : targets) { BigDecimal targetAlloc = BigDecimal.ZERO; Map 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 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 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> summary(@RequestParam(required = false) String period) { List 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 byPeriod = new LinkedHashMap<>(); Map 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 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); } }