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:
@@ -0,0 +1,352 @@
|
||||
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.Contract;
|
||||
import com.kaidi.oa.repository.ContractRepository;
|
||||
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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Contract master data. type is one of 经营合同 / 成本合同 / 采购合同;
|
||||
* status is one of 履约中 / 已结算 / 已终止.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/oa/contracts")
|
||||
public class ContractController {
|
||||
|
||||
private final ContractRepository contractRepo;
|
||||
private final com.kaidi.oa.repository.ContractTemplateRepository templateRepo;
|
||||
|
||||
public ContractController(ContractRepository contractRepo,
|
||||
com.kaidi.oa.repository.ContractTemplateRepository templateRepo) {
|
||||
this.contractRepo = contractRepo;
|
||||
this.templateRepo = templateRepo;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResp<List<Contract>> list(@RequestParam(required = false) Long projectId,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String status) {
|
||||
if (projectId != null) return ApiResp.ok(contractRepo.findByProjectId(projectId));
|
||||
if (type != null && !type.isBlank()) return ApiResp.ok(contractRepo.findByType(type));
|
||||
if (status != null && !status.isBlank()) return ApiResp.ok(contractRepo.findByStatus(status));
|
||||
return ApiResp.ok(contractRepo.findAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /search?keyword=xxx -> 合同全文关键词检索(含合同名称+乙方名称模糊匹配)。
|
||||
* 补齐缺口:合同档案库全文检索(附件扫描件全文需 ES,此处先闭合结构化字段检索)。
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
public ApiResp<List<Contract>> search(@RequestParam String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
throw new ApiException(400, "keyword 不能为空");
|
||||
}
|
||||
String kw = keyword.trim();
|
||||
List<Contract> byName = contractRepo.findByNameContaining(kw);
|
||||
List<Contract> byPartyB = contractRepo.findByPartyBContaining(kw);
|
||||
java.util.LinkedHashSet<Long> seen = new java.util.LinkedHashSet<>();
|
||||
List<Contract> merged = new ArrayList<>();
|
||||
for (Contract c : byName) {
|
||||
if (seen.add(c.getId())) merged.add(c);
|
||||
}
|
||||
for (Contract c : byPartyB) {
|
||||
if (seen.add(c.getId())) merged.add(c);
|
||||
}
|
||||
return ApiResp.ok(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /overdue-alert -> 履约逾期预警:列出状态=「履约中」且 signDate 超过180天且 paidAmount < amount 的合同。
|
||||
* 法务人员可据此主动介入追款/推函。供采购/销售/财务联动调用。
|
||||
*/
|
||||
@GetMapping("/overdue-alert")
|
||||
public ApiResp<List<Map<String, Object>>> overdueAlert() {
|
||||
List<Contract> active = contractRepo.findByStatus("履约中");
|
||||
LocalDate threshold = LocalDate.now().minusDays(180);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Contract c : active) {
|
||||
if (c.getSignDate() == null || c.getSignDate().isBlank()) continue;
|
||||
try {
|
||||
LocalDate sign = LocalDate.parse(c.getSignDate());
|
||||
if (sign.isBefore(threshold)) {
|
||||
BigDecimal paid = Money.nz(c.getPaidAmount());
|
||||
BigDecimal total = Money.nz(c.getAmount());
|
||||
if (total.compareTo(BigDecimal.ZERO) > 0 && paid.compareTo(total) < 0) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", c.getId());
|
||||
row.put("code", c.getCode());
|
||||
row.put("name", c.getName());
|
||||
row.put("partyB", c.getPartyB());
|
||||
row.put("amount", total);
|
||||
row.put("paidAmount", paid);
|
||||
row.put("unpaidAmount", total.subtract(paid));
|
||||
row.put("signDate", c.getSignDate());
|
||||
row.put("overdueDays", sign.until(LocalDate.now(), java.time.temporal.ChronoUnit.DAYS));
|
||||
row.put("status", c.getStatus());
|
||||
result.add(row);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
return ApiResp.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResp<Contract> get(@PathVariable Long id) {
|
||||
return ApiResp.ok(contractRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("contract not found: " + id)));
|
||||
}
|
||||
|
||||
public record CreateContractRequest(
|
||||
String code, String name, String type, String partyA, String partyB,
|
||||
Double amount, Long projectId, String companySubject, String signDate, String status,
|
||||
Double paidAmount, Double invoicedAmount, String custodian, Long parentId) {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResp<Contract> create(@RequestBody CreateContractRequest req) {
|
||||
if (req.name() == null || req.name().isBlank()) {
|
||||
throw new ApiException(400, "name is required");
|
||||
}
|
||||
Contract c = new Contract();
|
||||
c.setCode(req.code());
|
||||
c.setName(req.name());
|
||||
c.setType(req.type());
|
||||
c.setPartyA(req.partyA());
|
||||
c.setPartyB(req.partyB());
|
||||
c.setAmount(Money.of(req.amount() == null ? 0 : req.amount()));
|
||||
c.setProjectId(req.projectId());
|
||||
c.setCompanySubject(req.companySubject());
|
||||
c.setSignDate(req.signDate());
|
||||
c.setStatus(req.status() == null ? "履约中" : req.status());
|
||||
// 钱权分离(H13):已付/已开票额是系统口径,只能由 PaymentService.confirmPay 与
|
||||
// InvoiceController.applyContractDelta 回写,绝不接受客户端直传——新合同一律从 0 起算。
|
||||
c.setPaidAmount(Money.ZERO);
|
||||
c.setInvoicedAmount(Money.ZERO);
|
||||
c.setCustodian(req.custodian());
|
||||
c.setParentId(req.parentId());
|
||||
return ApiResp.ok(contractRepo.save(c));
|
||||
}
|
||||
|
||||
public record DraftFromTemplateRequest(
|
||||
Long templateId, String name, String type, String partyA, String partyB,
|
||||
Double amount, String companySubject, String signDate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /draft-from-template -> 合同闭环·从模板起草:校验模板存在并继承其合同类型,
|
||||
* 落库为一张「草稿」合同(已付/已开票从 0 起算,与现有 create 钱权分离口径一致)。
|
||||
* 起草后可送条款比对(/api/oa/clause-compare)与电子签(/api/oa/contracts/... esign)。
|
||||
*/
|
||||
@Transactional
|
||||
@PostMapping("/draft-from-template")
|
||||
public ApiResp<Contract> draftFromTemplate(@RequestBody DraftFromTemplateRequest req) {
|
||||
if (req.templateId() == null) {
|
||||
throw new ApiException(400, "templateId 不能为空");
|
||||
}
|
||||
var tpl = templateRepo.findById(req.templateId())
|
||||
.orElseThrow(() -> new NotFoundException("contract template not found: " + req.templateId()));
|
||||
if (req.name() == null || req.name().isBlank()) {
|
||||
throw new ApiException(400, "合同名称不能为空");
|
||||
}
|
||||
Contract c = new Contract();
|
||||
c.setCode("HT-CG" + (contractRepo.count() + 1));
|
||||
c.setName(req.name());
|
||||
// 合同类型缺省继承模板的 contractType。
|
||||
c.setType(req.type() == null || req.type().isBlank() ? tpl.getContractType() : req.type());
|
||||
c.setPartyA(req.partyA());
|
||||
c.setPartyB(req.partyB());
|
||||
c.setAmount(Money.of(req.amount()));
|
||||
c.setCompanySubject(req.companySubject());
|
||||
c.setSignDate(req.signDate());
|
||||
c.setStatus("草稿");
|
||||
c.setPaidAmount(Money.ZERO);
|
||||
c.setInvoicedAmount(Money.ZERO);
|
||||
return ApiResp.ok(contractRepo.save(c));
|
||||
}
|
||||
|
||||
public record CreateSubRequest(String name, String partyB, Double amount, String type) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /{id}/create-sub -> 在某总包合同下一键派生分包合同(工程项目生命周期⑧材料等分包合同)。
|
||||
* 自动:甲乙方倒换(我方=原总包乙方变发包甲方,分包商为乙方)、继承项目/主体、type=分包合同、status=草稿、parentId=总包。
|
||||
* 成本控制规则:分包累计金额不得超过总包合同金额,否则拒绝(防分包超支)。仅支持总包→分包两级。
|
||||
*/
|
||||
@PostMapping("/{id}/create-sub")
|
||||
@Transactional
|
||||
public ApiResp<Contract> createSub(@PathVariable Long id, @RequestBody CreateSubRequest req) {
|
||||
Contract parent = contractRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("contract not found: " + id));
|
||||
if (parent.getParentId() != null) {
|
||||
throw new ApiException(400, "分包合同不能再派生下级分包(仅支持总包→分包两级)");
|
||||
}
|
||||
BigDecimal newAmount = Money.of(req.amount());
|
||||
// 分包金额必须 >0:否则负额分包既是脏数据,又会在下面的带符号累计求和里"抵消"敞口,
|
||||
// 让实际超总包的正向分包绕过"分包不得超总包"成本控制。
|
||||
if (Money.lte0(newAmount)) {
|
||||
throw new ApiException(400, "分包金额必须大于 0");
|
||||
}
|
||||
// 一次性取出现有分包,既用于累计校验也用于编号,避免二次查询拉大 check-then-act 窗口
|
||||
List<Contract> existing = contractRepo.findByParentId(id);
|
||||
BigDecimal existingSubs = existing.stream()
|
||||
.map(c -> Money.nz(c.getAmount())).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (Money.gt(parent.getAmount(), BigDecimal.ZERO)
|
||||
&& Money.gt(Money.add(existingSubs, newAmount), parent.getAmount())) {
|
||||
throw new ApiException(400, "分包累计金额 " + Money.add(existingSubs, newAmount)
|
||||
+ " 超过总包合同金额 " + parent.getAmount() + ",请核对(成本控制:分包不得超总包)");
|
||||
}
|
||||
Contract sub = new Contract();
|
||||
// 残留:编号后缀按现有分包数 +1 生成,并发同时派生仍可能撞号;编号唯一性以 DB 约束为准(冲突→409)。
|
||||
sub.setCode((parent.getCode() == null || parent.getCode().isBlank() ? "HT-" + id : parent.getCode())
|
||||
+ "-F" + (existing.size() + 1));
|
||||
sub.setName(req.name() == null || req.name().isBlank() ? parent.getName() + " 分包合同" : req.name());
|
||||
sub.setType(req.type() == null || req.type().isBlank() ? "分包合同" : req.type());
|
||||
sub.setPartyA(parent.getPartyB());
|
||||
sub.setPartyB(req.partyB());
|
||||
sub.setAmount(Money.of(newAmount));
|
||||
sub.setProjectId(parent.getProjectId());
|
||||
sub.setCompanySubject(parent.getCompanySubject());
|
||||
sub.setStatus("草稿");
|
||||
sub.setCustodian(parent.getCustodian());
|
||||
sub.setParentId(parent.getId());
|
||||
return ApiResp.ok(contractRepo.save(sub));
|
||||
}
|
||||
|
||||
public record UpdateContractRequest(
|
||||
String code, String name, String type, String partyA, String partyB,
|
||||
Double amount, Long projectId, String companySubject, String signDate, String status,
|
||||
Double paidAmount, Double invoicedAmount, String custodian, Long parentId) {
|
||||
}
|
||||
|
||||
/** PATCH /{id} -> partial update; only non-null fields are applied. */
|
||||
@PatchMapping("/{id}")
|
||||
public ApiResp<Contract> update(@PathVariable Long id, @RequestBody UpdateContractRequest req) {
|
||||
Contract c = contractRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("contract not found: " + id));
|
||||
if (req.name() != null && req.name().isBlank()) {
|
||||
throw new ApiException(400, "name cannot be blank");
|
||||
}
|
||||
if (req.code() != null) c.setCode(req.code());
|
||||
if (req.name() != null) c.setName(req.name());
|
||||
if (req.type() != null) c.setType(req.type());
|
||||
if (req.partyA() != null) c.setPartyA(req.partyA());
|
||||
if (req.partyB() != null) c.setPartyB(req.partyB());
|
||||
if (req.amount() != null) c.setAmount(Money.of(req.amount()));
|
||||
if (req.projectId() != null) c.setProjectId(req.projectId());
|
||||
if (req.companySubject() != null) c.setCompanySubject(req.companySubject());
|
||||
if (req.signDate() != null) c.setSignDate(req.signDate());
|
||||
if (req.status() != null) c.setStatus(req.status());
|
||||
// 钱权分离(H13):已付/已开票额不接受客户端直改,只由付款结算/发票回写系统口径维护,
|
||||
// 防止任意调用方绕过资金审批直接篡改 contract.paidAmount/invoicedAmount。
|
||||
if (req.custodian() != null) c.setCustodian(req.custodian());
|
||||
if (req.parentId() != null) c.setParentId(req.parentId());
|
||||
return ApiResp.ok(contractRepo.save(c));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResp<Void> delete(@PathVariable Long id) {
|
||||
if (!contractRepo.existsById(id)) {
|
||||
throw new NotFoundException("contract not found: " + id);
|
||||
}
|
||||
contractRepo.deleteById(id);
|
||||
return ApiResp.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /{id}/print -> 合同打印预览(线上打印端点,补齐缺口 §2-3)。
|
||||
* 返回一个合同打印数据包(含完整合同字段、大小写金额、当前时间戳),
|
||||
* 前端据此渲染打印视图或调用 window.print()。
|
||||
*/
|
||||
@GetMapping("/{id}/print")
|
||||
public ApiResp<Map<String, Object>> print(@PathVariable Long id) {
|
||||
Contract c = contractRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("contract not found: " + id));
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("id", c.getId());
|
||||
data.put("code", c.getCode());
|
||||
data.put("name", c.getName());
|
||||
data.put("type", c.getType());
|
||||
data.put("partyA", c.getPartyA());
|
||||
data.put("partyB", c.getPartyB());
|
||||
data.put("amount", c.getAmount());
|
||||
data.put("amountChinese", ContractCostExtController.toChinese(c.getAmount()));
|
||||
data.put("signDate", c.getSignDate());
|
||||
data.put("status", c.getStatus());
|
||||
data.put("companySubject", c.getCompanySubject());
|
||||
data.put("custodian", c.getCustodian());
|
||||
data.put("paidAmount", c.getPaidAmount());
|
||||
data.put("invoicedAmount", c.getInvoicedAmount());
|
||||
data.put("printTime", java.time.Instant.now().toString());
|
||||
return ApiResp.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /prereview -> 合同附件预审(补齐缺口 §2-1/2-3)。
|
||||
* 校验:附件日期必须晚于或等于合同签订日期,否则 400。
|
||||
* 预审通过后合同状态推进到「预审通过」(前提:合同处于草稿/待审批,不在终态)。
|
||||
*/
|
||||
public record PrereviewRequest(
|
||||
Long contractId, String attachmentDate, String attachmentDesc, String operator) {
|
||||
}
|
||||
|
||||
@PostMapping("/prereview")
|
||||
@Transactional
|
||||
public ApiResp<Map<String, Object>> prereview(@RequestBody PrereviewRequest req) {
|
||||
if (req.contractId() == null) {
|
||||
throw new ApiException(400, "contractId 不能为空");
|
||||
}
|
||||
Contract c = contractRepo.findById(req.contractId())
|
||||
.orElseThrow(() -> new NotFoundException("contract not found: " + req.contractId()));
|
||||
// 合同终态不可预审
|
||||
if ("已结算".equals(c.getStatus()) || "已终止".equals(c.getStatus())) {
|
||||
throw new ApiException(400, "合同已结算或终止,不能再预审");
|
||||
}
|
||||
// 附件日期校验:附件日期必须 >= 合同签订日期
|
||||
if (req.attachmentDate() != null && !req.attachmentDate().isBlank()
|
||||
&& c.getSignDate() != null && !c.getSignDate().isBlank()) {
|
||||
LocalDate attDate = LocalDate.parse(req.attachmentDate());
|
||||
LocalDate signDate = LocalDate.parse(c.getSignDate());
|
||||
if (attDate.isBefore(signDate)) {
|
||||
throw new ApiException(400,
|
||||
"附件日期(" + req.attachmentDate() + ")早于合同签订日期(" + c.getSignDate() + "),无法提交");
|
||||
}
|
||||
}
|
||||
// 预审通过,推进状态
|
||||
String prevStatus = c.getStatus();
|
||||
if ("草稿".equals(c.getStatus()) || "待审批".equals(c.getStatus()) || c.getStatus() == null) {
|
||||
c.setStatus("预审通过");
|
||||
contractRepo.save(c);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("contractId", c.getId());
|
||||
result.put("contractName", c.getName());
|
||||
result.put("attachmentDate", req.attachmentDate());
|
||||
result.put("signDate", c.getSignDate());
|
||||
result.put("statusBefore", prevStatus);
|
||||
result.put("statusAfter", c.getStatus());
|
||||
result.put("operator", req.operator());
|
||||
result.put("prereviewTime", java.time.Instant.now().toString());
|
||||
result.put("passed", true);
|
||||
return ApiResp.ok(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user