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.FinConstructionInProgress; import com.kaidi.oa.domain.FixedAsset; import com.kaidi.oa.domain.Voucher; import com.kaidi.oa.repository.FinConstructionInProgressRepository; import com.kaidi.oa.repository.FixedAssetRepository; import com.kaidi.oa.repository.VoucherRepository; 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.LinkedHashMap; import java.util.List; import java.util.Map; /** * 财务部·在建工程管理与转固流程(资产模块缺口 LOW #7)。 * * 补全审计缺口:在建工程转固(construction-in-progress 转 fixed-asset)无专属流程/实体。 * * 端点: * GET / —— 在建工程台账列表(可按 status/department 过滤)。 * GET /{id} —— 单条明细。 * POST / —— 新建在建工程项目。 * PUT /{id} —— 编辑(补充成本/进度信息)。 * DELETE /{id} —— 删除(仅建设中/已终止状态)。 * POST /{id}/add-cost —— 累计归集成本(工程款/材料/人工等多次入账)。 * POST /{id}/ready-transfer —— 竣工验收→竣工待转(状态推进)。 * POST /{id}/transfer-asset —— 在建工程转固:生成 FixedAsset 实体并生成转固凭证。 * POST /{id}/abort —— 终止在建工程。 * GET /summary —— 在建工程汇总报告(各状态数量/总投资/超支分析)。 * * 写口:AuthInterceptor FINANCE_PREFIXES(/api/oa/fin-cip) 限 ADMIN/APPROVER。 */ @RestController @RequestMapping("/api/oa/fin-cip") public class FinConstructionInProgressController { private final FinConstructionInProgressRepository cipRepo; private final FixedAssetRepository assetRepo; private final VoucherRepository voucherRepo; public FinConstructionInProgressController(FinConstructionInProgressRepository cipRepo, FixedAssetRepository assetRepo, VoucherRepository voucherRepo) { this.cipRepo = cipRepo; this.assetRepo = assetRepo; this.voucherRepo = voucherRepo; } @GetMapping public ApiResp> list( @RequestParam(required = false) String status, @RequestParam(required = false) String department) { if (status != null && !status.isBlank()) return ApiResp.ok(cipRepo.findByStatus(status)); if (department != null && !department.isBlank()) return ApiResp.ok(cipRepo.findByDepartment(department)); return ApiResp.ok(cipRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(cipRepo.findById(id) .orElseThrow(() -> new NotFoundException("在建工程不存在: " + id))); } public record CipRequest( String projectName, String targetCategory, String startDate, String plannedEndDate, Double budgetedCost, String department, String projectManager, String remark) {} @PostMapping @Transactional public ApiResp create(@RequestBody CipRequest req) { if (req.projectName() == null || req.projectName().isBlank()) { throw new ApiException(400, "项目名称(projectName)不能为空"); } FinConstructionInProgress cip = new FinConstructionInProgress(); cip.setCipCode("CIP-" + (cipRepo.count() + 1)); cip.setProjectName(req.projectName()); cip.setTargetCategory(req.targetCategory()); cip.setStartDate(req.startDate() != null ? req.startDate() : LocalDate.now().toString()); cip.setPlannedEndDate(req.plannedEndDate()); BigDecimal budget = Money.of(req.budgetedCost()); cip.setBudgetedCost(budget); cip.setAccumulatedCost(BigDecimal.ZERO); cip.setRemainingBudget(budget); cip.setDepartment(req.department()); cip.setProjectManager(req.projectManager()); cip.setStatus(FinConstructionInProgress.S_UNDER); cip.setRemark(req.remark()); cip.setCreatedAt(Instant.now()); cip.setUpdatedAt(Instant.now()); return ApiResp.ok(cipRepo.save(cip)); } @PutMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody CipRequest req) { FinConstructionInProgress cip = cipRepo.findById(id) .orElseThrow(() -> new NotFoundException("在建工程不存在: " + id)); if (FinConstructionInProgress.S_DONE.equals(cip.getStatus())) { throw new ApiException(409, "已转固的在建工程不允许修改"); } if (req.projectName() != null && !req.projectName().isBlank()) cip.setProjectName(req.projectName()); if (req.targetCategory() != null) cip.setTargetCategory(req.targetCategory()); if (req.plannedEndDate() != null) cip.setPlannedEndDate(req.plannedEndDate()); if (req.budgetedCost() != null) { BigDecimal budget = Money.of(req.budgetedCost()); cip.setBudgetedCost(budget); cip.setRemainingBudget(budget.subtract(Money.nz(cip.getAccumulatedCost())).max(BigDecimal.ZERO)); } if (req.department() != null) cip.setDepartment(req.department()); if (req.projectManager() != null) cip.setProjectManager(req.projectManager()); if (req.remark() != null) cip.setRemark(req.remark()); cip.setUpdatedAt(Instant.now()); return ApiResp.ok(cipRepo.save(cip)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { FinConstructionInProgress cip = cipRepo.findById(id) .orElseThrow(() -> new NotFoundException("在建工程不存在: " + id)); if (!FinConstructionInProgress.S_UNDER.equals(cip.getStatus()) && !FinConstructionInProgress.S_ABORTED.equals(cip.getStatus())) { throw new ApiException(409, "仅建设中/已终止状态可删除"); } cipRepo.deleteById(id); return ApiResp.ok(null); } // ============================================================ // 归集成本 // ============================================================ public record AddCostRequest(Double amount, String costType, String remark, String operator) {} @PostMapping("/{id}/add-cost") @Transactional public ApiResp addCost(@PathVariable Long id, @RequestBody AddCostRequest req) { FinConstructionInProgress cip = cipRepo.findById(id) .orElseThrow(() -> new NotFoundException("在建工程不存在: " + id)); if (FinConstructionInProgress.S_DONE.equals(cip.getStatus()) || FinConstructionInProgress.S_ABORTED.equals(cip.getStatus())) { throw new ApiException(409, "已转固或已终止的在建工程不允许继续归集成本"); } BigDecimal amt = Money.of(req.amount()); if (amt.compareTo(BigDecimal.ZERO) <= 0) { throw new ApiException(400, "归集金额必须大于 0"); } BigDecimal newAccum = Money.nz(cip.getAccumulatedCost()).add(amt); cip.setAccumulatedCost(newAccum.setScale(2, RoundingMode.HALF_UP)); cip.setRemainingBudget(Money.nz(cip.getBudgetedCost()).subtract(newAccum).max(BigDecimal.ZERO)); cip.setUpdatedAt(Instant.now()); // 生成在建工程成本归集凭证 Voucher v = new Voucher(); v.setVoucherNo("CIP-COST-" + id + "-" + System.currentTimeMillis() % 100000); v.setVoucherDate(LocalDate.now().toString()); v.setSummary("在建工程[" + cip.getProjectName() + "]归集" + (req.costType() != null ? req.costType() : "工程成本") + " " + amt + "元"); v.setDebitAccount("1701 在建工程"); v.setCreditAccount("1002 银行存款"); v.setAmount(amt); v.setStatus(Voucher.S_DRAFT); v.setPreparer(req.operator() != null ? req.operator() : "系统"); v.setIsReversal(false); v.setReversed(false); v.setCreatedAt(Instant.now()); v.setSourceType("fin-cip-cost"); v.setSourceId(id); voucherRepo.save(v); return ApiResp.ok(cipRepo.save(cip)); } // ============================================================ // 竣工待转 // ============================================================ public record ReadyRequest(String actualEndDate, String approver) {} @PostMapping("/{id}/ready-transfer") @Transactional public ApiResp readyTransfer(@PathVariable Long id, @RequestBody ReadyRequest req) { FinConstructionInProgress cip = cipRepo.findById(id) .orElseThrow(() -> new NotFoundException("在建工程不存在: " + id)); if (!FinConstructionInProgress.S_UNDER.equals(cip.getStatus()) && !FinConstructionInProgress.S_PAUSED.equals(cip.getStatus())) { throw new ApiException(409, "仅建设中/暂停状态可推进至竣工待转"); } cip.setStatus(FinConstructionInProgress.S_READY); cip.setActualEndDate(req.actualEndDate() != null ? req.actualEndDate() : LocalDate.now().toString()); cip.setApprover(req.approver()); cip.setUpdatedAt(Instant.now()); return ApiResp.ok(cipRepo.save(cip)); } // ============================================================ // 转固定资产(核心流程) // ============================================================ public record TransferRequest( String assetNo, String assetName, Integer usefulLifeYears, String depreciationMethod, String transferDate, String approver, String operator) {} @PostMapping("/{id}/transfer-asset") @Transactional public ApiResp> transferToFixedAsset(@PathVariable Long id, @RequestBody TransferRequest req) { FinConstructionInProgress cip = cipRepo.findById(id) .orElseThrow(() -> new NotFoundException("在建工程不存在: " + id)); if (!FinConstructionInProgress.S_READY.equals(cip.getStatus())) { throw new ApiException(409, "仅竣工待转状态可执行转固操作(当前状态:" + cip.getStatus() + ")"); } if (cip.getAssetId() != null) { throw new ApiException(409, "该在建工程已转固,固定资产 ID=" + cip.getAssetId()); } if (req.usefulLifeYears() == null || req.usefulLifeYears() <= 0) { throw new ApiException(400, "折旧年限(usefulLifeYears)必须大于0"); } BigDecimal origVal = Money.nz(cip.getAccumulatedCost()); String transferDate = req.transferDate() != null ? req.transferDate() : LocalDate.now().toString(); String method = req.depreciationMethod() != null ? req.depreciationMethod() : "年限平均法"; int months = req.usefulLifeYears() * 12; BigDecimal monthlyDep = origVal.divide(BigDecimal.valueOf(months), 2, RoundingMode.HALF_UP); // 创建固定资产 FixedAsset asset = new FixedAsset(); asset.setAssetNo(req.assetNo() != null ? req.assetNo() : "FA-" + LocalDate.now().toString().replace("-", "") + "-" + id); asset.setName(req.assetName() != null ? req.assetName() : cip.getProjectName()); asset.setCategory(cip.getTargetCategory() != null ? cip.getTargetCategory() : "在建转固"); asset.setOriginalValue(origVal); asset.setAccumDepreciation(BigDecimal.ZERO); asset.setNetValue(origVal); asset.setDepreciationMethod(method); asset.setUsefulLifeYears(req.usefulLifeYears()); asset.setMonthlyDepreciation(monthlyDep); asset.setDepreciatedMonths(0); asset.setStatus(FixedAsset.S_IN_USE); asset.setDepartment(cip.getDepartment()); asset.setAcquireDate(transferDate); asset.setOwner(req.operator() != null ? req.operator() : "财务部"); asset.setCreatedAt(Instant.now()); FixedAsset savedAsset = assetRepo.save(asset); // 生成转固凭证(借 固定资产/贷 在建工程) Voucher v = new Voucher(); v.setVoucherNo("CIP-TRANS-" + id + "-" + transferDate.replace("-", "")); v.setVoucherDate(transferDate); v.setSummary("在建工程[" + cip.getProjectName() + "]转固定资产[" + asset.getName() + "],转固金额 " + origVal + " 元"); v.setDebitAccount("1601 固定资产"); v.setCreditAccount("1701 在建工程"); v.setAmount(origVal); v.setStatus(Voucher.S_POSTED); v.setPreparer(req.approver() != null ? req.approver() : "财务部"); v.setIsReversal(false); v.setReversed(false); v.setCreatedAt(Instant.now()); v.setSourceType("fin-cip-transfer"); v.setSourceId(id); Voucher savedV = voucherRepo.save(v); // 更新在建工程状态 cip.setStatus(FinConstructionInProgress.S_DONE); cip.setTransferDate(transferDate); cip.setApprover(req.approver()); cip.setAssetId(savedAsset.getId()); cip.setUpdatedAt(Instant.now()); cipRepo.save(cip); Map result = new LinkedHashMap<>(); result.put("cipId", id); result.put("cipCode", cip.getCipCode()); result.put("projectName", cip.getProjectName()); result.put("transferAmount", origVal); result.put("fixedAssetId", savedAsset.getId()); result.put("fixedAssetNo", savedAsset.getAssetNo()); result.put("depreciationMethod", method); result.put("usefulLifeYears", req.usefulLifeYears()); result.put("monthlyDepreciation", monthlyDep); result.put("voucherId", savedV.getId()); result.put("voucherNo", savedV.getVoucherNo()); result.put("message", "在建工程转固成功,已生成固定资产和转固凭证"); return ApiResp.ok(result); } // ============================================================ // 终止 // ============================================================ public record AbortRequest(String reason, String operator) {} @PostMapping("/{id}/abort") @Transactional public ApiResp abort(@PathVariable Long id, @RequestBody AbortRequest req) { FinConstructionInProgress cip = cipRepo.findById(id) .orElseThrow(() -> new NotFoundException("在建工程不存在: " + id)); if (FinConstructionInProgress.S_DONE.equals(cip.getStatus())) { throw new ApiException(409, "已转固的在建工程不允许终止"); } cip.setStatus(FinConstructionInProgress.S_ABORTED); cip.setRemark((cip.getRemark() != null ? cip.getRemark() + " | " : "") + "终止原因:" + (req.reason() != null ? req.reason() : "未说明")); cip.setUpdatedAt(Instant.now()); return ApiResp.ok(cipRepo.save(cip)); } // ============================================================ // 汇总报告 // ============================================================ @GetMapping("/summary") public ApiResp> summary() { List all = cipRepo.findAll(); Map byStatus = new LinkedHashMap<>(); BigDecimal totalBudget = BigDecimal.ZERO; BigDecimal totalCost = BigDecimal.ZERO; int overBudgetCount = 0; for (FinConstructionInProgress c : all) { byStatus.merge(c.getStatus() != null ? c.getStatus() : "未知", 1L, Long::sum); totalBudget = totalBudget.add(Money.nz(c.getBudgetedCost())); totalCost = totalCost.add(Money.nz(c.getAccumulatedCost())); if (Money.nz(c.getAccumulatedCost()).compareTo(Money.nz(c.getBudgetedCost())) > 0) { overBudgetCount++; } } Map result = new LinkedHashMap<>(); result.put("totalCount", all.size()); result.put("byStatus", byStatus); result.put("totalBudget", totalBudget.setScale(2, RoundingMode.HALF_UP)); result.put("totalAccumulatedCost", totalCost.setScale(2, RoundingMode.HALF_UP)); result.put("totalVariance", totalCost.subtract(totalBudget).setScale(2, RoundingMode.HALF_UP)); result.put("overBudgetCount", overBudgetCount); result.put("note", "超支项目数量=" + overBudgetCount + "(实际成本>预算)"); return ApiResp.ok(result); } }