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,395 @@
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.FinEmpLoan;
import com.kaidi.oa.domain.Voucher;
import com.kaidi.oa.repository.FinEmpLoanRepository;
import com.kaidi.oa.repository.VoucherRepository;
import org.springframework.scheduling.annotation.Scheduled;
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.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 财务部·员工借款管理(缺口 #2)。
*
* 审计缺口:借款管理(员工借款申请/还款核销/逾期扣工资)无专属控制器,
* 仅 FinAdvances 覆盖预收/预付场景,员工借款未独立建模。
*
* 本控制器提供:
* GET / —— 列表(按 status/department/loanType 过滤)
* GET /{id} —— 单条明细
* POST / —— 新建借款申请(草稿)
* PUT /{id} —— 编辑(草稿/已驳回可修改)
* DELETE /{id} —— 删除(仅草稿)
* POST /{id}/submit —— 提交审批:草稿 → 待审批
* POST /{id}/approve —— 审批通过:待审批 → 已批准,自动生成借款凭证
* POST /{id}/reject —— 驳回:待审批 → 草稿
* POST /{id}/repay —— 还款核销:已批准/还款中,累加还款额,推进状态
* POST /{id}/deduct-salary —— 标记从工资扣除(逾期处理)
* GET /overdue-check —— 逾期检查清单(超还款日且未结清)
* GET /summary —— 汇总(各状态借款总额/未还余额)
*
* 调度:每日 09:00 自动检查逾期,超过预计还款日且未结清的置为「逾期」状态。
*
* 写口:FINANCE_PREFIXES(/api/oa/fin-emp-loans) 限 ADMIN/APPROVER。
*/
@RestController
@RequestMapping("/api/oa/fin-emp-loans")
public class FinEmpLoanController {
private final FinEmpLoanRepository loanRepo;
private final VoucherRepository voucherRepo;
public FinEmpLoanController(FinEmpLoanRepository loanRepo, VoucherRepository voucherRepo) {
this.loanRepo = loanRepo;
this.voucherRepo = voucherRepo;
}
// ===================== CRUD =====================
@GetMapping
public ApiResp<List<FinEmpLoan>> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String department,
@RequestParam(required = false) String loanType,
@RequestParam(required = false) String employeeName) {
if (status != null && department != null)
return ApiResp.ok(loanRepo.findByStatusAndDepartment(status, department));
if (status != null) return ApiResp.ok(loanRepo.findByStatus(status));
if (department != null) return ApiResp.ok(loanRepo.findByDepartment(department));
if (loanType != null) return ApiResp.ok(loanRepo.findByLoanType(loanType));
if (employeeName != null) return ApiResp.ok(loanRepo.findByEmployeeName(employeeName));
return ApiResp.ok(loanRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<FinEmpLoan> get(@PathVariable Long id) {
return ApiResp.ok(loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)));
}
public record LoanRequest(
String employeeName, String employeeId, String department,
String loanType, String purpose,
Double loanAmount, String applyDate, String expectedRepayDate,
String remark, String operator) {}
@PostMapping
public ApiResp<FinEmpLoan> create(@RequestBody LoanRequest req) {
if (req.employeeName() == null || req.employeeName().isBlank())
throw new ApiException(400, "员工姓名(employeeName)不能为空");
if (req.loanAmount() == null || req.loanAmount() <= 0)
throw new ApiException(400, "借款金额(loanAmount)必须大于 0");
FinEmpLoan loan = new FinEmpLoan();
loan.setLoanCode("EL-" + System.currentTimeMillis() % 1000000);
loan.setEmployeeName(req.employeeName());
loan.setEmployeeId(req.employeeId());
loan.setDepartment(req.department());
loan.setLoanType(req.loanType() == null ? FinEmpLoan.TYPE_OTHER : req.loanType());
loan.setPurpose(req.purpose());
BigDecimal amt = Money.of(req.loanAmount());
loan.setLoanAmount(amt);
loan.setRepaidAmount(BigDecimal.ZERO);
loan.setRemainAmount(amt);
loan.setApplyDate(req.applyDate() != null ? req.applyDate() : LocalDate.now().toString());
loan.setExpectedRepayDate(req.expectedRepayDate());
loan.setDeductFromSalary(Boolean.FALSE);
loan.setStatus(FinEmpLoan.S_DRAFT);
loan.setRemark(req.remark());
loan.setOperator(req.operator());
loan.setCreatedAt(Instant.now());
loan.setUpdatedAt(Instant.now());
return ApiResp.ok(loanRepo.save(loan));
}
@PutMapping("/{id}")
public ApiResp<FinEmpLoan> update(@PathVariable Long id, @RequestBody LoanRequest req) {
FinEmpLoan loan = loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id));
if (!FinEmpLoan.S_DRAFT.equals(loan.getStatus()))
throw new ApiException(409, "仅草稿状态可编辑,当前状态:" + loan.getStatus());
if (req.employeeName() != null) loan.setEmployeeName(req.employeeName());
if (req.employeeId() != null) loan.setEmployeeId(req.employeeId());
if (req.department() != null) loan.setDepartment(req.department());
if (req.loanType() != null) loan.setLoanType(req.loanType());
if (req.purpose() != null) loan.setPurpose(req.purpose());
if (req.loanAmount() != null) {
BigDecimal amt = Money.of(req.loanAmount());
loan.setLoanAmount(amt);
loan.setRemainAmount(amt.subtract(Money.nz(loan.getRepaidAmount())));
}
if (req.applyDate() != null) loan.setApplyDate(req.applyDate());
if (req.expectedRepayDate() != null) loan.setExpectedRepayDate(req.expectedRepayDate());
if (req.remark() != null) loan.setRemark(req.remark());
loan.setUpdatedAt(Instant.now());
return ApiResp.ok(loanRepo.save(loan));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
FinEmpLoan loan = loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id));
if (!FinEmpLoan.S_DRAFT.equals(loan.getStatus()))
throw new ApiException(409, "仅草稿状态可删除,当前状态:" + loan.getStatus());
loanRepo.deleteById(id);
return ApiResp.ok(null);
}
// ===================== 状态机 =====================
@PostMapping("/{id}/submit")
@Transactional
public ApiResp<FinEmpLoan> submit(@PathVariable Long id) {
FinEmpLoan loan = loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id));
if (!FinEmpLoan.S_DRAFT.equals(loan.getStatus()))
throw new ApiException(409, "仅草稿状态可提交审批,当前状态:" + loan.getStatus());
if (Money.nz(loan.getLoanAmount()).signum() <= 0)
throw new ApiException(400, "借款金额必须大于 0");
loan.setStatus(FinEmpLoan.S_PENDING);
loan.setUpdatedAt(Instant.now());
return ApiResp.ok(loanRepo.save(loan));
}
public record ApproveRequest(String approver, String approvalComment) {}
/** 审批通过:待审批 → 已批准,自动生成借款发放凭证。 */
@PostMapping("/{id}/approve")
@Transactional
public ApiResp<Map<String, Object>> approve(@PathVariable Long id,
@RequestBody(required = false) ApproveRequest req) {
FinEmpLoan loan = loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id));
if (!FinEmpLoan.S_PENDING.equals(loan.getStatus()))
throw new ApiException(409, "仅待审批状态可审批,当前状态:" + loan.getStatus());
loan.setStatus(FinEmpLoan.S_APPROVED);
if (req != null) {
loan.setApprover(req.approver());
loan.setApprovalComment(req.approvalComment());
}
loan.setApprovalDate(LocalDate.now().toString());
loan.setUpdatedAt(Instant.now());
loanRepo.save(loan);
// 自动生成借款发放凭证(借 其他应收款 / 贷 银行存款)
Voucher v = new Voucher();
v.setVoucherNo("EL-" + loan.getLoanCode() + "-" + System.currentTimeMillis() % 10000);
v.setVoucherDate(LocalDate.now().toString());
v.setSummary("员工借款发放:" + loan.getEmployeeName() + " / " + loan.getLoanType()
+ " / " + loan.getPurpose());
v.setDebitAccount("1221 其他应收款");
v.setCreditAccount("1002 银行存款");
v.setAmount(Money.nz(loan.getLoanAmount()));
v.setStatus(Voucher.S_DRAFT);
v.setPreparer(req != null && req.approver() != null ? req.approver() : "系统");
v.setReversed(Boolean.FALSE);
v.setIsReversal(Boolean.FALSE);
v.setSourceType("emp-loan");
v.setSourceId(loan.getId());
v.setCreatedAt(Instant.now());
Voucher saved = voucherRepo.save(v);
loan.setVoucherId(saved.getId());
loanRepo.save(loan);
Map<String, Object> result = new LinkedHashMap<>();
result.put("loanId", loan.getId());
result.put("loanCode", loan.getLoanCode());
result.put("status", loan.getStatus());
result.put("voucherId", saved.getId());
result.put("message", "借款审批通过,已生成借款发放凭证 #" + saved.getId());
return ApiResp.ok(result);
}
public record RejectRequest(String approver, String approvalComment) {}
/** 驳回:待审批 → 草稿。 */
@PostMapping("/{id}/reject")
@Transactional
public ApiResp<FinEmpLoan> reject(@PathVariable Long id,
@RequestBody RejectRequest req) {
FinEmpLoan loan = loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id));
if (!FinEmpLoan.S_PENDING.equals(loan.getStatus()))
throw new ApiException(409, "仅待审批状态可驳回,当前状态:" + loan.getStatus());
if (req.approvalComment() == null || req.approvalComment().isBlank())
throw new ApiException(400, "驳回须填写审批意见(approvalComment)");
loan.setStatus(FinEmpLoan.S_DRAFT);
loan.setApprover(req.approver());
loan.setApprovalComment(req.approvalComment());
loan.setApprovalDate(LocalDate.now().toString());
loan.setUpdatedAt(Instant.now());
return ApiResp.ok(loanRepo.save(loan));
}
public record RepayRequest(Double repayAmount, String repayDate,
String relatedExpenseCode, String operator) {}
/** 还款核销:已批准/还款中 → 更新余额,若余额为 0 则置为「已结清」。 */
@PostMapping("/{id}/repay")
@Transactional
public ApiResp<FinEmpLoan> repay(@PathVariable Long id, @RequestBody RepayRequest req) {
FinEmpLoan loan = loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id));
if (!FinEmpLoan.S_APPROVED.equals(loan.getStatus())
&& !FinEmpLoan.S_REPAYING.equals(loan.getStatus())
&& !FinEmpLoan.S_OVERDUE.equals(loan.getStatus()))
throw new ApiException(409, "仅已批准/还款中/逾期状态可还款,当前状态:" + loan.getStatus());
if (req.repayAmount() == null || req.repayAmount() <= 0)
throw new ApiException(400, "还款金额(repayAmount)必须大于 0");
BigDecimal repay = Money.of(req.repayAmount());
BigDecimal remain = Money.nz(loan.getRemainAmount());
if (repay.compareTo(remain) > 0)
throw new ApiException(400, "还款金额 " + repay + " 超过未还余额 " + remain);
BigDecimal newRepaid = Money.nz(loan.getRepaidAmount()).add(repay);
BigDecimal newRemain = remain.subtract(repay);
loan.setRepaidAmount(newRepaid);
loan.setRemainAmount(newRemain);
loan.setStatus(newRemain.signum() == 0 ? FinEmpLoan.S_SETTLED : FinEmpLoan.S_REPAYING);
if (newRemain.signum() == 0) loan.setSettleDate(req.repayDate() != null ? req.repayDate() : LocalDate.now().toString());
if (req.relatedExpenseCode() != null) loan.setRelatedExpenseCode(req.relatedExpenseCode());
loan.setUpdatedAt(Instant.now());
// 生成还款凭证(借 银行存款 / 贷 其他应收款)
Voucher v = new Voucher();
v.setVoucherNo("ELR-" + loan.getLoanCode() + "-" + System.currentTimeMillis() % 10000);
v.setVoucherDate(req.repayDate() != null ? req.repayDate() : LocalDate.now().toString());
v.setSummary("员工还款核销:" + loan.getEmployeeName() + " 还款 " + repay + "");
v.setDebitAccount("1002 银行存款");
v.setCreditAccount("1221 其他应收款");
v.setAmount(repay);
v.setStatus(Voucher.S_DRAFT);
v.setPreparer(req.operator() != null ? req.operator() : "系统");
v.setReversed(Boolean.FALSE);
v.setIsReversal(Boolean.FALSE);
v.setSourceType("emp-loan-repay");
v.setSourceId(loan.getId());
v.setCreatedAt(Instant.now());
voucherRepo.save(v);
return ApiResp.ok(loanRepo.save(loan));
}
/** 标记从工资代扣:逾期/已批准 → 设置 deductFromSalary=true。 */
@PostMapping("/{id}/deduct-salary")
@Transactional
public ApiResp<FinEmpLoan> deductSalary(@PathVariable Long id) {
FinEmpLoan loan = loanRepo.findById(id)
.orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id));
if (FinEmpLoan.S_SETTLED.equals(loan.getStatus()))
throw new ApiException(409, "已结清的借款无需从工资扣除");
loan.setDeductFromSalary(Boolean.TRUE);
loan.setUpdatedAt(Instant.now());
return ApiResp.ok(loanRepo.save(loan));
}
// ===================== 聚合端点 =====================
/** 逾期检查:返回超过预计还款日且未结清的借款清单。 */
@GetMapping("/overdue-check")
public ApiResp<List<Map<String, Object>>> overdueCheck() {
List<FinEmpLoan> all = loanRepo.findAll();
String today = LocalDate.now().toString();
List<Map<String, Object>> result = new ArrayList<>();
for (FinEmpLoan loan : all) {
if (FinEmpLoan.S_SETTLED.equals(loan.getStatus())) continue;
String repayDate = loan.getExpectedRepayDate();
if (repayDate == null) continue;
if (repayDate.compareTo(today) < 0) {
long overdueDays = ChronoUnit.DAYS.between(
LocalDate.parse(repayDate), LocalDate.now());
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", loan.getId());
row.put("loanCode", loan.getLoanCode());
row.put("employeeName", loan.getEmployeeName());
row.put("department", loan.getDepartment());
row.put("loanAmount", Money.nz(loan.getLoanAmount()));
row.put("remainAmount", Money.nz(loan.getRemainAmount()));
row.put("expectedRepayDate", repayDate);
row.put("overdueDays", overdueDays);
row.put("status", loan.getStatus());
row.put("deductFromSalary", loan.getDeductFromSalary());
result.add(row);
}
}
return ApiResp.ok(result);
}
/** 汇总:各状态借款金额统计 + 未还余额汇总。 */
@GetMapping("/summary")
public ApiResp<Map<String, Object>> summary() {
List<FinEmpLoan> all = loanRepo.findAll();
BigDecimal totalLoan = BigDecimal.ZERO;
BigDecimal totalRepaid = BigDecimal.ZERO;
BigDecimal totalRemain = BigDecimal.ZERO;
BigDecimal totalOverdue = BigDecimal.ZERO;
long overdueCount = 0;
for (FinEmpLoan loan : all) {
totalLoan = totalLoan.add(Money.nz(loan.getLoanAmount()));
totalRepaid = totalRepaid.add(Money.nz(loan.getRepaidAmount()));
totalRemain = totalRemain.add(Money.nz(loan.getRemainAmount()));
if (FinEmpLoan.S_OVERDUE.equals(loan.getStatus())) {
totalOverdue = totalOverdue.add(Money.nz(loan.getRemainAmount()));
overdueCount++;
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("totalLoanAmount", totalLoan);
result.put("totalRepaidAmount", totalRepaid);
result.put("totalRemainAmount", totalRemain);
result.put("totalOverdueAmount", totalOverdue);
result.put("overdueCount", overdueCount);
result.put("totalCount", all.size());
return ApiResp.ok(result);
}
// ===================== 调度 — 每日逾期自动标记 =====================
/**
* 每日 09:00 自动检查逾期:超过预计还款日且未结清的记录置为「逾期」状态。
*/
@Scheduled(cron = "0 0 9 * * *")
@Transactional
public void autoMarkOverdue() {
String today = LocalDate.now().toString();
List<FinEmpLoan> active = loanRepo.findAll().stream()
.filter(l -> !FinEmpLoan.S_SETTLED.equals(l.getStatus())
&& !FinEmpLoan.S_DRAFT.equals(l.getStatus())
&& !FinEmpLoan.S_PENDING.equals(l.getStatus()))
.toList();
for (FinEmpLoan loan : active) {
String repayDate = loan.getExpectedRepayDate();
if (repayDate != null && repayDate.compareTo(today) < 0
&& !FinEmpLoan.S_OVERDUE.equals(loan.getStatus())) {
long days = ChronoUnit.DAYS.between(LocalDate.parse(repayDate), LocalDate.now());
loan.setStatus(FinEmpLoan.S_OVERDUE);
loan.setOverdueDays((int) days);
loan.setUpdatedAt(Instant.now());
loanRepo.save(loan);
}
}
}
}