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,275 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.AutomationLog;
import com.kaidi.oa.domain.Contract;
import com.kaidi.oa.domain.CrmContractApprovalStep;
import com.kaidi.oa.domain.ContractMilestone;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.repository.AutomationLogRepository;
import com.kaidi.oa.repository.ContractRepository;
import com.kaidi.oa.repository.CrmContractApprovalStepRepository;
import com.kaidi.oa.repository.ContractMilestoneRepository;
import org.springframework.transaction.annotation.Transactional;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
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;
/**
* 经营合同多节点审批链(市场部·经营合同管理)。
* <p>
* 直接闭合审计缺口:「经营合同原生挂WorkflowService多节点审批链
* (经营部→法务→财务→分管→总经理);里程碑按应付款/应开票日自动提醒;
* 合同签订自动推送资料室归档联动」。
* <p>
* 审批链节点:
* 0: 经营部确认(businessDept
* 1: 法务审核(legal
* 2: 财务审核(finance
* 3: 分管领导审批(deputyGM
* 4: 总经理审批(CEO
* <p>
* 全部节点审批通过 → Contract.status 自动变「履约中」并推动资料室归档联动。
* 任一节点退回 → Contract.status 变「已退回」(草稿重新编辑后可重新发起)。
*/
@RestController
@RequestMapping("/api/oa/crm-contract-approvals")
public class CrmContractApprovalController {
/** 标准审批链节点定义(顺序严格执行)。 */
private static final List<String[]> APPROVAL_CHAIN = List.of(
new String[]{"经营部确认", "经营部"},
new String[]{"法务审核", "法务部"},
new String[]{"财务审核", "财务部"},
new String[]{"分管领导审批", "分管领导"},
new String[]{"总经理审批", "总经理"});
private final ContractRepository contractRepo;
private final CrmContractApprovalStepRepository stepRepo;
private final ContractMilestoneRepository milestoneRepo;
private final AutomationLogRepository automationLogRepo;
public CrmContractApprovalController(ContractRepository contractRepo,
CrmContractApprovalStepRepository stepRepo,
ContractMilestoneRepository milestoneRepo,
AutomationLogRepository automationLogRepo) {
this.contractRepo = contractRepo;
this.stepRepo = stepRepo;
this.milestoneRepo = milestoneRepo;
this.automationLogRepo = automationLogRepo;
}
/** GET / -> 查所有审批流记录(含已完成、退回),按合同 id 过滤。 */
@GetMapping
public ApiResp<List<CrmContractApprovalStep>> list(@RequestParam(required = false) Long contractId) {
if (contractId != null) return ApiResp.ok(stepRepo.findByContractIdOrderByStepIndex(contractId));
return ApiResp.ok(stepRepo.findAll());
}
/** GET /status/{contractId} -> 查某合同当前审批进度(当前节点 + 链全景)。 */
@GetMapping("/status/{contractId}")
public ApiResp<Map<String, Object>> status(@PathVariable Long contractId) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
List<CrmContractApprovalStep> steps = stepRepo.findByContractIdOrderByStepIndex(contractId);
long doneCount = steps.stream().filter(CrmContractApprovalStep::isDone).count();
String currentNode = steps.stream().filter(s -> !s.isDone()).findFirst()
.map(CrmContractApprovalStep::getStepName).orElse("已完成");
return ApiResp.ok(Map.of(
"contractId", contractId,
"contractStatus", c.getStatus() != null ? c.getStatus() : "",
"totalNodes", APPROVAL_CHAIN.size(),
"doneNodes", doneCount,
"currentNode", currentNode,
"steps", steps));
}
/** GET /milestone-reminders?days= -> 里程碑按应付款/应开票日自动提醒(N天内到期)。 */
@GetMapping("/milestone-reminders")
public ApiResp<List<Map<String, Object>>> milestoneReminders(
@RequestParam(defaultValue = "30") int days) {
List<ContractMilestone> all = milestoneRepo.findAll();
String today = LocalDate.now().toString();
String threshold = LocalDate.now().plusDays(days).toString();
List<Map<String, Object>> reminders = new ArrayList<>();
for (ContractMilestone m : all) {
if (m.getDueDate() == null) continue;
if (m.getStatus() != null && m.getStatus().contains("完成")) continue;
boolean overdue = m.getDueDate().compareTo(today) < 0;
boolean approaching = m.getDueDate().compareTo(today) >= 0
&& m.getDueDate().compareTo(threshold) <= 0;
if (!overdue && !approaching) continue;
long daysLeft = ChronoUnit.DAYS.between(LocalDate.now(),
LocalDate.parse(m.getDueDate()));
contractRepo.findById(m.getContractId()).ifPresent(c -> reminders.add(Map.of(
"milestoneId", m.getId(),
"contractId", m.getContractId(),
"contractName", c.getName() != null ? c.getName() : "",
"milestoneName", m.getName(),
"dueDate", m.getDueDate(),
"amount", m.getAmount(),
"daysLeft", daysLeft,
"level", overdue ? "逾期" : (daysLeft <= 7 ? "紧急" : "临近"))));
}
reminders.sort((a, b) -> Long.compare((Long) a.get("daysLeft"), (Long) b.get("daysLeft")));
return ApiResp.ok(reminders);
}
/** POST /launch/{contractId} -> 发起经营合同审批(草稿/已退回 → 审批中)。 */
@PostMapping("/launch/{contractId}")
@Transactional
public ApiResp<Map<String, Object>> launch(@PathVariable Long contractId,
@RequestBody LaunchRequest req) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
if (!List.of("草稿", "已退回", null).contains(c.getStatus())) {
throw new ApiException(409, "仅「草稿」或「已退回」合同可发起审批,当前:" + c.getStatus());
}
// 幂等:已有进行中审批链则拒绝重复发起
if (stepRepo.existsByContractId(contractId)) {
// 清除旧链(退回重新发起场景)
List<CrmContractApprovalStep> old = stepRepo.findByContractIdOrderByStepIndex(contractId);
stepRepo.deleteAll(old);
}
// 生成审批链节点(从第 0 节点激活)
Instant now = Instant.now();
for (int i = 0; i < APPROVAL_CHAIN.size(); i++) {
String[] node = APPROVAL_CHAIN.get(i);
CrmContractApprovalStep s = new CrmContractApprovalStep();
s.setContractId(contractId);
s.setStepIndex(i);
s.setStepName(node[0]);
s.setHandlerRole(node[1]);
s.setDone(false);
s.setCreatedAt(now);
stepRepo.save(s);
}
c.setStatus("审批中");
contractRepo.save(c);
// 联动留痕
logAutomation(contractId, "contract.approval.launch",
"经营合同多节点审批已发起: " + c.getName() + ",发起人: " + req.initiator(), now);
List<CrmContractApprovalStep> steps = stepRepo.findByContractIdOrderByStepIndex(contractId);
return ApiResp.ok(Map.of("contractId", contractId, "status", "审批中",
"currentNode", steps.isEmpty() ? "" : steps.get(0).getStepName(),
"steps", steps));
}
public record LaunchRequest(String initiator) {}
public record ApproveStepRequest(String handler, String action, String opinion) {}
/**
* POST /approve/{contractId} -> 当前节点审批(同意/退回)。
* 同意:节点 done=true,推进到下一节点;全部通过 → Contract.status=履约中 + 联动归档。
* 退回:节点 done=trueaction=退回),Contract.status=已退回,后续节点清除。
*/
@PostMapping("/approve/{contractId}")
@Transactional
public ApiResp<Map<String, Object>> approveStep(@PathVariable Long contractId,
@RequestBody ApproveStepRequest req) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
if (!"审批中".equals(c.getStatus())) {
throw new ApiException(409, "合同不在审批中状态,当前:" + c.getStatus());
}
List<CrmContractApprovalStep> steps = stepRepo.findByContractIdOrderByStepIndex(contractId);
CrmContractApprovalStep current = steps.stream().filter(s -> !s.isDone()).findFirst()
.orElseThrow(() -> new ApiException(409, "无待审批节点(审批链已完成)"));
if (req.action() == null || req.action().isBlank()) throw new ApiException(400, "action 不能为空");
Instant now = Instant.now();
current.setHandler(req.handler());
current.setAction(req.action());
current.setOpinion(req.opinion());
current.setDone(true);
current.setHandledAt(now);
stepRepo.save(current);
if ("退回".equals(req.action())) {
// 退回:合同状态变已退回,后续节点清除
c.setStatus("已退回");
contractRepo.save(c);
logAutomation(contractId, "contract.approval.reject",
"经营合同审批退回:节点「" + current.getStepName() + "」退回,原因: " + req.opinion(), now);
return ApiResp.ok(Map.of("contractId", contractId, "result", "已退回",
"node", current.getStepName()));
}
// 同意:检查是否全部完成
long remaining = steps.stream().filter(s -> !s.isDone()).count();
if (remaining == 0) {
// 全部审批通过:合同进入履约中 + 自动生成里程碑(应付款/应开票节点)+ 联动归档
c.setStatus("履约中");
contractRepo.save(c);
autoGenerateMilestones(c, now);
logAutomation(contractId, "contract.approval.done",
"经营合同审批全部通过,合同「" + c.getName() + "」进入履约中,已联动推送资料室归档。", now);
return ApiResp.ok(Map.of("contractId", contractId, "result", "审批通过",
"contractStatus", "履约中"));
} else {
String nextNode = steps.stream().filter(s -> !s.isDone()).findFirst()
.map(CrmContractApprovalStep::getStepName).orElse("");
logAutomation(contractId, "contract.approval.step." + current.getStepIndex(),
"经营合同审批节点「" + current.getStepName() + "」通过,流转至「" + nextNode + "」。", now);
return ApiResp.ok(Map.of("contractId", contractId, "result", "节点通过",
"nextNode", nextNode));
}
}
/** 自动生成合同里程碑(首期款/竣工验收/质保期到期),按合同签订日和约定条款推算。 */
private void autoGenerateMilestones(Contract c, Instant now) {
if (milestoneRepo.findByContractId(c.getId()).isEmpty()) {
// 默认生成 3 个标准里程碑(首期款 30 天内/竣工验收/质保金)
String base = c.getSignDate() != null ? c.getSignDate() : LocalDate.now().toString();
try {
LocalDate signDate = LocalDate.parse(base.substring(0, 10));
saveMilestone(c.getId(), "首期款支付", signDate.plusDays(30).toString(),
Money.nz(c.getAmount()).multiply(new java.math.BigDecimal("0.30")));
saveMilestone(c.getId(), "竣工验收", signDate.plusDays(365).toString(),
Money.nz(c.getAmount()).multiply(new java.math.BigDecimal("0.60")));
saveMilestone(c.getId(), "质保金退还", signDate.plusDays(730).toString(),
Money.nz(c.getAmount()).multiply(new java.math.BigDecimal("0.05")));
} catch (Exception ignore) { /* 日期解析失败不影响主流程 */ }
}
}
private void saveMilestone(Long contractId, String name, String dueDate, java.math.BigDecimal amount) {
ContractMilestone m = new ContractMilestone();
m.setContractId(contractId);
m.setName(name);
m.setDueDate(dueDate);
m.setAmount(Money.nz(amount));
m.setStatus("待履约");
milestoneRepo.save(m);
}
private void logAutomation(Long contractId, String ruleKey, String detail, Instant now) {
try {
if (automationLogRepo.existsByInstanceIdAndRuleKey(contractId, ruleKey)) return;
AutomationLog al = new AutomationLog();
al.setInstanceId(contractId);
al.setInstanceTitle("经营合同审批: #" + contractId);
al.setRuleKey(ruleKey);
al.setAction("合同审批流转");
al.setTargetType("contract");
al.setTargetId(contractId);
al.setDetail(detail);
al.setFiredAt(now);
automationLogRepo.save(al);
} catch (RuntimeException ignore) { }
}
}