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

361 lines
18 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.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<FinConstructionInProgress>> 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<FinConstructionInProgress> 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<FinConstructionInProgress> 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<FinConstructionInProgress> 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<Void> 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<FinConstructionInProgress> 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<FinConstructionInProgress> 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<Map<String, Object>> 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<String, Object> 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<FinConstructionInProgress> 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<Map<String, Object>> summary() {
List<FinConstructionInProgress> all = cipRepo.findAll();
Map<String, Long> 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<String, Object> 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);
}
}