恢复点(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>
361 lines
18 KiB
Java
361 lines
18 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.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);
|
||
}
|
||
}
|