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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,360 @@
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);
}
}