恢复点(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>
597 lines
27 KiB
Java
597 lines
27 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.CslContract;
|
||
import com.kaidi.oa.domain.CslProject;
|
||
import com.kaidi.oa.repository.CslContractRepository;
|
||
import com.kaidi.oa.repository.CslProjectRepository;
|
||
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.PatchMapping;
|
||
import org.springframework.web.bind.annotation.PathVariable;
|
||
import org.springframework.web.bind.annotation.PostMapping;
|
||
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.time.temporal.ChronoUnit;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 设计研究中心 / 咨询可研院 —— CSL 专属合同台账(补审计 PARTIAL 缺口·模块5·合同管理)。
|
||
*
|
||
* 解决缺口:CslProject 仅靠 contractAmount 字段承载金额,与通用 ContractController 需按
|
||
* 客户名手工关联(模糊/易错),且无 CSL 专属合同台账页——客户-项目-合同-结算全链条断层。
|
||
*
|
||
* 本控制器建立:
|
||
* 1. CslContract 专属实体,显式 cslProjectId 外键精确关联 CslProject;
|
||
* 2. 完整合同状态机(草稿→待审查→审查通过→签署生效→履约中→已竣工→结算中→已结算/已终止);
|
||
* 3. 法务审查端点(/legal-review,记录审查意见,状态迁移到"审查通过"或打回);
|
||
* 4. 付款登记端点(/record-payment,累加 receivedAmount,系统口径,不接受客户端直传);
|
||
* 5. 开票登记端点(/record-invoice,累加 invoicedAmount,系统口径);
|
||
* 6. 合同变更端点(/change,变更计数 +1,可选更新金额/期限);
|
||
* 7. 客户-项目-合同-结算全链条聚合看板(/chain?cslProjectId=x);
|
||
* 8. 结算逾期预警(/alerts/overdue)。
|
||
*
|
||
* 金额口径:totalAmount 含税 BigDecimal;amountExclTax 由控制器按 taxRate 自动计算;
|
||
* receivedAmount/invoicedAmount 系统口径,只能通过专属端点递增。
|
||
*
|
||
* 写口登记进 AuthInterceptor FINANCE_PREFIXES;读口登记进 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。
|
||
*/
|
||
@RestController
|
||
@RequestMapping("/api/oa/csl-contracts")
|
||
public class CslContractController {
|
||
|
||
private final CslContractRepository contractRepo;
|
||
private final CslProjectRepository projectRepo;
|
||
|
||
public CslContractController(CslContractRepository contractRepo,
|
||
CslProjectRepository projectRepo) {
|
||
this.contractRepo = contractRepo;
|
||
this.projectRepo = projectRepo;
|
||
}
|
||
|
||
// ===== 台账 CRUD =====
|
||
|
||
@GetMapping
|
||
public ApiResp<List<CslContract>> list(
|
||
@RequestParam(required = false) Long cslProjectId,
|
||
@RequestParam(required = false) String contractStatus,
|
||
@RequestParam(required = false) String keyword,
|
||
@RequestParam(required = false) String manager) {
|
||
if (cslProjectId != null) {
|
||
return ApiResp.ok(contractRepo.findByCslProjectId(cslProjectId));
|
||
}
|
||
if (contractStatus != null && !contractStatus.isBlank()) {
|
||
return ApiResp.ok(contractRepo.findByContractStatus(contractStatus));
|
||
}
|
||
if (keyword != null && !keyword.isBlank()) {
|
||
java.util.LinkedHashSet<Long> seen = new java.util.LinkedHashSet<>();
|
||
List<CslContract> merged = new ArrayList<>();
|
||
for (CslContract c : contractRepo.findByNameContaining(keyword)) {
|
||
if (seen.add(c.getId())) merged.add(c);
|
||
}
|
||
for (CslContract c : contractRepo.findByPartyAContaining(keyword)) {
|
||
if (seen.add(c.getId())) merged.add(c);
|
||
}
|
||
return ApiResp.ok(merged);
|
||
}
|
||
if (manager != null && !manager.isBlank()) {
|
||
return ApiResp.ok(contractRepo.findByManager(manager));
|
||
}
|
||
return ApiResp.ok(contractRepo.findAll());
|
||
}
|
||
|
||
@GetMapping("/{id}")
|
||
public ApiResp<CslContract> get(@PathVariable Long id) {
|
||
return ApiResp.ok(find(id));
|
||
}
|
||
|
||
public record CslContractRequest(
|
||
String contractCode, String name, Long cslProjectId,
|
||
String partyA, String partyB, String contractType,
|
||
Double totalAmount, Double taxRate,
|
||
String signDate, String dueDate,
|
||
String manager, String paymentPlanDesc, String invoiceReq, String remark) {
|
||
}
|
||
|
||
/**
|
||
* 新建 CSL 合同:
|
||
* - 自动生成合同编号(若未提供);
|
||
* - 若提供 cslProjectId,校验项目存在且自动回填甲方;
|
||
* - amountExclTax 按 totalAmount / (1 + taxRate/100) 自动计算;
|
||
* - receivedAmount / invoicedAmount 初始化为 0(系统口径,只能通过专属端点递增);
|
||
* - 初始状态:草稿。
|
||
*/
|
||
@PostMapping
|
||
@Transactional
|
||
public ApiResp<CslContract> create(@RequestBody CslContractRequest req) {
|
||
if (req.name() == null || req.name().isBlank()) {
|
||
throw new ApiException(400, "合同名称不能为空");
|
||
}
|
||
CslContract c = new CslContract();
|
||
|
||
// 合同编号
|
||
String code = req.contractCode();
|
||
if (code == null || code.isBlank()) {
|
||
code = "CSLHT-" + (contractRepo.count() + 1);
|
||
} else {
|
||
final String codeTrim = code.trim();
|
||
contractRepo.findByContractCode(codeTrim).ifPresent(existing -> {
|
||
throw new ApiException(409, "合同编号已存在:" + codeTrim);
|
||
});
|
||
}
|
||
c.setContractCode(code.trim());
|
||
c.setName(req.name().trim());
|
||
|
||
// 项目关联(精确外键)
|
||
if (req.cslProjectId() != null) {
|
||
CslProject proj = projectRepo.findById(req.cslProjectId())
|
||
.orElseThrow(() -> new NotFoundException("csl project not found: " + req.cslProjectId()));
|
||
c.setCslProjectId(proj.getId());
|
||
// 自动回填甲方(若未显式传入)
|
||
if ((req.partyA() == null || req.partyA().isBlank()) && proj.getClient() != null) {
|
||
c.setPartyA(proj.getClient());
|
||
} else {
|
||
c.setPartyA(req.partyA());
|
||
}
|
||
} else {
|
||
c.setPartyA(req.partyA());
|
||
}
|
||
|
||
c.setPartyB(req.partyB());
|
||
c.setContractType(req.contractType() == null ? "咨询服务合同" : req.contractType());
|
||
c.setTotalAmount(Money.of(req.totalAmount()));
|
||
BigDecimal taxRate = req.taxRate() == null ? BigDecimal.ZERO : Money.of(req.taxRate());
|
||
c.setTaxRate(taxRate);
|
||
c.setAmountExclTax(calcExclTax(c.getTotalAmount(), taxRate));
|
||
c.setReceivedAmount(Money.ZERO);
|
||
c.setInvoicedAmount(Money.ZERO);
|
||
c.setContractStatus("草稿");
|
||
c.setChangeCount(0);
|
||
c.setSignDate(req.signDate());
|
||
c.setDueDate(req.dueDate());
|
||
c.setManager(req.manager());
|
||
c.setPaymentPlanDesc(req.paymentPlanDesc());
|
||
c.setInvoiceReq(req.invoiceReq());
|
||
c.setRemark(req.remark());
|
||
c.setCreatedAt(Instant.now());
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
/**
|
||
* 更新合同基础字段(名称/类型/期限/负责人/收款开票说明等)。
|
||
* totalAmount/taxRate 可修改(会重算 amountExclTax);
|
||
* receivedAmount/invoicedAmount 由专属端点控制,此口不接受。
|
||
*/
|
||
@PatchMapping("/{id}")
|
||
@Transactional
|
||
public ApiResp<CslContract> update(@PathVariable Long id, @RequestBody CslContractRequest req) {
|
||
CslContract c = find(id);
|
||
if ("已结算".equals(c.getContractStatus()) || "已终止".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "合同已" + c.getContractStatus() + ",不能修改基础信息");
|
||
}
|
||
if (req.name() != null && !req.name().isBlank()) c.setName(req.name().trim());
|
||
if (req.cslProjectId() != null) {
|
||
projectRepo.findById(req.cslProjectId())
|
||
.orElseThrow(() -> new NotFoundException("csl project not found: " + req.cslProjectId()));
|
||
c.setCslProjectId(req.cslProjectId());
|
||
}
|
||
if (req.partyA() != null) c.setPartyA(req.partyA());
|
||
if (req.partyB() != null) c.setPartyB(req.partyB());
|
||
if (req.contractType() != null) c.setContractType(req.contractType());
|
||
if (req.totalAmount() != null) {
|
||
c.setTotalAmount(Money.of(req.totalAmount()));
|
||
BigDecimal taxRate = req.taxRate() != null ? Money.of(req.taxRate()) : Money.nz(c.getTaxRate());
|
||
c.setTaxRate(taxRate);
|
||
c.setAmountExclTax(calcExclTax(c.getTotalAmount(), taxRate));
|
||
} else if (req.taxRate() != null) {
|
||
BigDecimal taxRate = Money.of(req.taxRate());
|
||
c.setTaxRate(taxRate);
|
||
c.setAmountExclTax(calcExclTax(Money.nz(c.getTotalAmount()), taxRate));
|
||
}
|
||
if (req.signDate() != null) c.setSignDate(req.signDate());
|
||
if (req.dueDate() != null) c.setDueDate(req.dueDate());
|
||
if (req.manager() != null) c.setManager(req.manager());
|
||
if (req.paymentPlanDesc() != null) c.setPaymentPlanDesc(req.paymentPlanDesc());
|
||
if (req.invoiceReq() != null) c.setInvoiceReq(req.invoiceReq());
|
||
if (req.remark() != null) c.setRemark(req.remark());
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
@DeleteMapping("/{id}")
|
||
public ApiResp<Void> delete(@PathVariable Long id) {
|
||
CslContract c = find(id);
|
||
if (!"草稿".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "仅草稿状态合同可删除,当前状态:" + c.getContractStatus());
|
||
}
|
||
contractRepo.deleteById(id);
|
||
return ApiResp.ok(null);
|
||
}
|
||
|
||
// ===== 状态机 =====
|
||
|
||
/** 提交审查:草稿 → 待审查。 */
|
||
@PostMapping("/{id}/submit-review")
|
||
@Transactional
|
||
public ApiResp<CslContract> submitReview(@PathVariable Long id) {
|
||
CslContract c = find(id);
|
||
if (!"草稿".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "仅草稿状态可提交审查,当前:" + c.getContractStatus());
|
||
}
|
||
c.setContractStatus("待审查");
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
public record LegalReviewRequest(String opinion, String remark) {
|
||
}
|
||
|
||
/**
|
||
* 法务审查:待审查 → 审查通过 / 打回草稿。
|
||
* opinion: 审查通过 / 待整改(打回) / 不通过(终止合同流程)。
|
||
*/
|
||
@PostMapping("/{id}/legal-review")
|
||
@Transactional
|
||
public ApiResp<CslContract> legalReview(@PathVariable Long id,
|
||
@RequestBody LegalReviewRequest req) {
|
||
CslContract c = find(id);
|
||
if (!"待审查".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "仅待审查状态可做法务审查,当前:" + c.getContractStatus());
|
||
}
|
||
if (req.opinion() == null || req.opinion().isBlank()) {
|
||
throw new ApiException(400, "审查意见不能为空(审查通过 / 待整改 / 不通过)");
|
||
}
|
||
c.setLegalReview(req.opinion());
|
||
c.setLegalRemark(req.remark());
|
||
if ("审查通过".equals(req.opinion())) {
|
||
c.setContractStatus("审查通过");
|
||
} else if ("不通过".equals(req.opinion())) {
|
||
c.setContractStatus("已终止");
|
||
} else {
|
||
c.setContractStatus("草稿"); // 待整改打回草稿
|
||
}
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
/** 签署生效:审查通过 → 签署生效 → 履约中(同步打上签署日期)。 */
|
||
@PostMapping("/{id}/sign")
|
||
@Transactional
|
||
public ApiResp<CslContract> sign(@PathVariable Long id,
|
||
@RequestBody(required = false) Map<String, String> body) {
|
||
CslContract c = find(id);
|
||
if (!"审查通过".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "仅审查通过状态可签署生效,当前:" + c.getContractStatus());
|
||
}
|
||
String signDate = body != null ? body.get("signDate") : null;
|
||
c.setSignDate(signDate != null && !signDate.isBlank() ? signDate : LocalDate.now().toString());
|
||
c.setContractStatus("履约中");
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
/** 标记竣工:履约中 → 已竣工(服务交付完成,进入结算)。 */
|
||
@PostMapping("/{id}/complete")
|
||
@Transactional
|
||
public ApiResp<CslContract> complete(@PathVariable Long id) {
|
||
CslContract c = find(id);
|
||
if (!"履约中".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "仅履约中状态可标记竣工,当前:" + c.getContractStatus());
|
||
}
|
||
c.setContractStatus("已竣工");
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
/** 开始结算:已竣工 → 结算中。 */
|
||
@PostMapping("/{id}/start-settlement")
|
||
@Transactional
|
||
public ApiResp<CslContract> startSettlement(@PathVariable Long id) {
|
||
CslContract c = find(id);
|
||
if (!"已竣工".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "仅已竣工状态可开始结算,当前:" + c.getContractStatus());
|
||
}
|
||
c.setContractStatus("结算中");
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
/** 结算完成:结算中 → 已结算(需已收款额 = 合同总额)。 */
|
||
@PostMapping("/{id}/settle")
|
||
@Transactional
|
||
public ApiResp<CslContract> settle(@PathVariable Long id) {
|
||
CslContract c = find(id);
|
||
if (!"结算中".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "仅结算中状态可结算完成,当前:" + c.getContractStatus());
|
||
}
|
||
BigDecimal total = Money.nz(c.getTotalAmount());
|
||
BigDecimal received = Money.nz(c.getReceivedAmount());
|
||
if (Money.gt(total, received)) {
|
||
throw new ApiException(409,
|
||
"已收款额(" + received + ")小于合同总额(" + total + "),不能结算完成");
|
||
}
|
||
c.setContractStatus("已结算");
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
/** 终止合同:任意非结算完成状态 → 已终止(需提供原因)。 */
|
||
@PostMapping("/{id}/terminate")
|
||
@Transactional
|
||
public ApiResp<CslContract> terminate(@PathVariable Long id,
|
||
@RequestBody(required = false) Map<String, String> body) {
|
||
CslContract c = find(id);
|
||
if ("已结算".equals(c.getContractStatus())) {
|
||
throw new ApiException(409, "合同已结算,不能终止");
|
||
}
|
||
String reason = body != null ? body.get("reason") : null;
|
||
if (reason != null && !reason.isBlank()) {
|
||
String existing = c.getRemark() == null ? "" : c.getRemark();
|
||
c.setRemark((existing.isBlank() ? "" : existing + " | ") + "终止原因:" + reason);
|
||
}
|
||
c.setContractStatus("已终止");
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
// ===== 付款登记(系统口径,只增不减) =====
|
||
|
||
public record PaymentRequest(Double amount, String payDate, String remark) {
|
||
}
|
||
|
||
/**
|
||
* 登记收款(系统口径 receivedAmount 递增)。
|
||
* 累计收款不得超过合同总额(超收拦截)。
|
||
* 合同状态须在 履约中 / 已竣工 / 结算中 之一才能收款。
|
||
*/
|
||
@PostMapping("/{id}/record-payment")
|
||
@Transactional
|
||
public ApiResp<CslContract> recordPayment(@PathVariable Long id,
|
||
@RequestBody PaymentRequest req) {
|
||
CslContract c = find(id);
|
||
List<String> payableStatuses = List.of("履约中", "已竣工", "结算中");
|
||
if (!payableStatuses.contains(c.getContractStatus())) {
|
||
throw new ApiException(409, "当前合同状态不允许登记收款:" + c.getContractStatus());
|
||
}
|
||
BigDecimal amount = Money.of(req.amount());
|
||
if (Money.lte0(amount)) {
|
||
throw new ApiException(400, "收款金额必须大于 0");
|
||
}
|
||
BigDecimal newReceived = Money.add(c.getReceivedAmount(), amount);
|
||
if (Money.gt(newReceived, c.getTotalAmount())) {
|
||
throw new ApiException(409,
|
||
"累计收款额(" + newReceived + ")超过合同总额(" + c.getTotalAmount() + ")");
|
||
}
|
||
c.setReceivedAmount(newReceived);
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
// ===== 开票登记(系统口径,只增不减) =====
|
||
|
||
public record InvoiceRequest(Double amount, String invoiceDate, String invoiceNo, String remark) {
|
||
}
|
||
|
||
/**
|
||
* 登记开票(系统口径 invoicedAmount 递增)。
|
||
* 累计开票额不得超过合同总额(超开拦截)。
|
||
*/
|
||
@PostMapping("/{id}/record-invoice")
|
||
@Transactional
|
||
public ApiResp<CslContract> recordInvoice(@PathVariable Long id,
|
||
@RequestBody InvoiceRequest req) {
|
||
CslContract c = find(id);
|
||
List<String> invoiceableStatuses = List.of("履约中", "已竣工", "结算中", "已结算");
|
||
if (!invoiceableStatuses.contains(c.getContractStatus())) {
|
||
throw new ApiException(409, "当前合同状态不允许登记开票:" + c.getContractStatus());
|
||
}
|
||
BigDecimal amount = Money.of(req.amount());
|
||
if (Money.lte0(amount)) {
|
||
throw new ApiException(400, "开票金额必须大于 0");
|
||
}
|
||
BigDecimal newInvoiced = Money.add(c.getInvoicedAmount(), amount);
|
||
if (Money.gt(newInvoiced, c.getTotalAmount())) {
|
||
throw new ApiException(409,
|
||
"累计开票额(" + newInvoiced + ")超过合同总额(" + c.getTotalAmount() + ")");
|
||
}
|
||
c.setInvoicedAmount(newInvoiced);
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
// ===== 合同变更 =====
|
||
|
||
public record ChangeRequest(Double newTotalAmount, String newDueDate, String changeReason) {
|
||
}
|
||
|
||
/**
|
||
* 合同变更(合同变更次数 +1)。
|
||
* 可选更新合同总额(含税)和/或履约截止日期,并重算不含税额。
|
||
* 仅履约中 / 已竣工 / 结算中 状态可发起变更。
|
||
*/
|
||
@PostMapping("/{id}/change")
|
||
@Transactional
|
||
public ApiResp<CslContract> change(@PathVariable Long id,
|
||
@RequestBody ChangeRequest req) {
|
||
CslContract c = find(id);
|
||
List<String> changeable = List.of("履约中", "已竣工", "结算中");
|
||
if (!changeable.contains(c.getContractStatus())) {
|
||
throw new ApiException(409, "当前合同状态不允许发起变更:" + c.getContractStatus());
|
||
}
|
||
if (req.changeReason() == null || req.changeReason().isBlank()) {
|
||
throw new ApiException(400, "变更原因不能为空");
|
||
}
|
||
if (req.newTotalAmount() != null) {
|
||
BigDecimal newTotal = Money.of(req.newTotalAmount());
|
||
if (Money.lte0(newTotal)) {
|
||
throw new ApiException(400, "变更后合同总额必须大于 0");
|
||
}
|
||
c.setTotalAmount(newTotal);
|
||
c.setAmountExclTax(calcExclTax(newTotal, Money.nz(c.getTaxRate())));
|
||
}
|
||
if (req.newDueDate() != null && !req.newDueDate().isBlank()) {
|
||
c.setDueDate(req.newDueDate());
|
||
}
|
||
int count = c.getChangeCount() == null ? 0 : c.getChangeCount();
|
||
c.setChangeCount(count + 1);
|
||
// 变更记录追加到备注(简单留痕,完整变更记录可建子表扩展)
|
||
String existing = c.getRemark() == null ? "" : c.getRemark();
|
||
String note = "第" + (count + 1) + "次变更(" + LocalDate.now() + "):" + req.changeReason();
|
||
c.setRemark((existing.isBlank() ? "" : existing + " | ") + note);
|
||
c.setUpdatedAt(Instant.now());
|
||
return ApiResp.ok(contractRepo.save(c));
|
||
}
|
||
|
||
// ===== 客户-项目-合同-结算全链条聚合看板 =====
|
||
|
||
public record ChainView(
|
||
Long cslProjectId, String projectCode, String projectName,
|
||
String projectStage, String client, String manager,
|
||
int contractCount, BigDecimal totalContractAmount,
|
||
BigDecimal totalReceived, BigDecimal totalInvoiced,
|
||
BigDecimal uncollected, List<CslContract> contracts) {
|
||
}
|
||
|
||
/**
|
||
* 全链条看板:给定 cslProjectId,返回该项目的所有 CSL 合同以及聚合统计。
|
||
* 打通"客户→项目→合同→收款/开票"全链条数据(解决缺口核心)。
|
||
*/
|
||
@GetMapping("/chain")
|
||
public ApiResp<ChainView> chain(@RequestParam Long cslProjectId) {
|
||
CslProject proj = projectRepo.findById(cslProjectId)
|
||
.orElseThrow(() -> new NotFoundException("csl project not found: " + cslProjectId));
|
||
List<CslContract> contracts = contractRepo.findByCslProjectId(cslProjectId);
|
||
|
||
BigDecimal totalAmount = contracts.stream()
|
||
.map(c -> Money.nz(c.getTotalAmount()))
|
||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||
BigDecimal totalReceived = contracts.stream()
|
||
.map(c -> Money.nz(c.getReceivedAmount()))
|
||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||
BigDecimal totalInvoiced = contracts.stream()
|
||
.map(c -> Money.nz(c.getInvoicedAmount()))
|
||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||
BigDecimal uncollected = Money.sub(totalAmount, totalReceived);
|
||
|
||
return ApiResp.ok(new ChainView(
|
||
proj.getId(), proj.getCode(), proj.getName(),
|
||
proj.getStage(), proj.getClient(), proj.getManager(),
|
||
contracts.size(), totalAmount, totalReceived, totalInvoiced,
|
||
uncollected, contracts));
|
||
}
|
||
|
||
// ===== 结算逾期预警 =====
|
||
|
||
public record OverdueContract(
|
||
Long id, String contractCode, String name,
|
||
String partyA, String dueDate, long overdueDays,
|
||
BigDecimal totalAmount, BigDecimal receivedAmount,
|
||
BigDecimal uncollected, String contractStatus) {
|
||
}
|
||
|
||
/**
|
||
* 逾期预警:扫描所有"履约中/已竣工/结算中"状态合同,找出 dueDate 已过且仍有未收款余额的,
|
||
* 按逾期天数降序,供合同管理员催收/介入。
|
||
*/
|
||
@GetMapping("/alerts/overdue")
|
||
public ApiResp<List<OverdueContract>> overdueAlerts() {
|
||
LocalDate today = LocalDate.now();
|
||
List<OverdueContract> result = new ArrayList<>();
|
||
List<String> activeStatuses = List.of("履约中", "已竣工", "结算中");
|
||
for (CslContract c : contractRepo.findAll()) {
|
||
if (!activeStatuses.contains(c.getContractStatus())) continue;
|
||
if (c.getDueDate() == null || c.getDueDate().isBlank()) continue;
|
||
LocalDate due;
|
||
try {
|
||
due = LocalDate.parse(c.getDueDate().trim().substring(0, Math.min(10, c.getDueDate().trim().length())));
|
||
} catch (Exception e) {
|
||
continue;
|
||
}
|
||
if (!due.isBefore(today)) continue;
|
||
BigDecimal uncollected = Money.sub(c.getTotalAmount(), c.getReceivedAmount());
|
||
if (uncollected.compareTo(BigDecimal.ZERO) <= 0) continue;
|
||
long overdueDays = ChronoUnit.DAYS.between(due, today);
|
||
result.add(new OverdueContract(
|
||
c.getId(), c.getContractCode(), c.getName(),
|
||
c.getPartyA(), c.getDueDate(), overdueDays,
|
||
Money.nz(c.getTotalAmount()), Money.nz(c.getReceivedAmount()),
|
||
uncollected, c.getContractStatus()));
|
||
}
|
||
result.sort((a, b) -> Long.compare(b.overdueDays(), a.overdueDays()));
|
||
return ApiResp.ok(result);
|
||
}
|
||
|
||
// ===== 台账统计概览 =====
|
||
|
||
public record ContractSummary(
|
||
long total, long draft, long reviewing, long approved,
|
||
long active, long completed, long settled, long terminated,
|
||
BigDecimal totalContractAmount, BigDecimal totalReceived, BigDecimal totalInvoiced,
|
||
BigDecimal totalUncollected) {
|
||
}
|
||
|
||
/** GET /summary -> CSL 合同台账统计(各状态数量+金额汇总)。 */
|
||
@GetMapping("/summary")
|
||
public ApiResp<ContractSummary> summary() {
|
||
List<CslContract> all = contractRepo.findAll();
|
||
long draft = count(all, "草稿");
|
||
long reviewing = count(all, "待审查");
|
||
long approved = count(all, "审查通过");
|
||
long active = count(all, "履约中");
|
||
long completed = count(all, "已竣工");
|
||
long settled = count(all, "已结算");
|
||
long terminated = count(all, "已终止");
|
||
BigDecimal totalAmount = all.stream().map(c -> Money.nz(c.getTotalAmount()))
|
||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||
BigDecimal totalReceived = all.stream().map(c -> Money.nz(c.getReceivedAmount()))
|
||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||
BigDecimal totalInvoiced = all.stream().map(c -> Money.nz(c.getInvoicedAmount()))
|
||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||
BigDecimal uncollected = Money.sub(totalAmount, totalReceived);
|
||
return ApiResp.ok(new ContractSummary(all.size(), draft, reviewing, approved,
|
||
active, completed, settled, terminated,
|
||
totalAmount, totalReceived, totalInvoiced, uncollected));
|
||
}
|
||
|
||
// ===== helpers =====
|
||
|
||
private CslContract find(Long id) {
|
||
return contractRepo.findById(id)
|
||
.orElseThrow(() -> new NotFoundException("csl contract not found: " + id));
|
||
}
|
||
|
||
/**
|
||
* 不含税额 = 含税额 / (1 + taxRate/100),标度 2 HALF_UP。
|
||
* taxRate 为 0(无税)时直接返回含税额。
|
||
*/
|
||
private static BigDecimal calcExclTax(BigDecimal totalAmount, BigDecimal taxRate) {
|
||
BigDecimal nzTotal = Money.nz(totalAmount);
|
||
BigDecimal nzRate = Money.nz(taxRate);
|
||
if (nzRate.compareTo(BigDecimal.ZERO) == 0) {
|
||
return nzTotal;
|
||
}
|
||
// divisor = 1 + taxRate/100
|
||
BigDecimal divisor = BigDecimal.ONE.add(
|
||
nzRate.divide(new BigDecimal("100"), 6, RoundingMode.HALF_UP));
|
||
return nzTotal.divide(divisor, 2, RoundingMode.HALF_UP);
|
||
}
|
||
|
||
private static long count(List<CslContract> list, String status) {
|
||
return list.stream().filter(c -> status.equals(c.getContractStatus())).count();
|
||
}
|
||
}
|