Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/CrmPettyCashController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

238 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.CrmPettyCash;
import com.kaidi.oa.repository.CrmPettyCashRepository;
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.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 办事处备用金/借款管理(市场部·费用与预算控制·备用金闭环)。
* <p>
* 直接闭合审计缺口:「无备用金/借款/核销闭环实体与端点:无法做员工预借款、
* 备用金台账、借支后凭报销冲销核销的资金周转管理」。
* <p>
* 业务动作:
* POST /{id}/submit 草稿 → 待审批
* POST /{id}/approve 待审批 → 已批准(approve=true)或 已驳回(approve=false
* POST /{id}/disburse 已批准 → 已借出(拨款)
* POST /{id}/verify 已借出 → 核销申请 → 核销完成(传实际金额,自动算余额)
* GET /summary 台账汇总(待审批数、在途借款总额、已借未核销总额)
*/
@RestController
@RequestMapping("/api/oa/crm-petty-cash")
public class CrmPettyCashController {
private final CrmPettyCashRepository repo;
public CrmPettyCashController(CrmPettyCashRepository repo) {
this.repo = repo;
}
@GetMapping
public ApiResp<List<CrmPettyCash>> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String orgUnit,
@RequestParam(required = false) String type,
@RequestParam(required = false) String applicant) {
if (status != null && !status.isBlank()) return ApiResp.ok(repo.findByStatus(status));
if (orgUnit != null && !orgUnit.isBlank()) return ApiResp.ok(repo.findByOrgUnit(orgUnit));
if (type != null && !type.isBlank()) return ApiResp.ok(repo.findByType(type));
if (applicant != null && !applicant.isBlank()) return ApiResp.ok(repo.findByApplicant(applicant));
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<CrmPettyCash> get(@PathVariable Long id) {
return ApiResp.ok(repo.findById(id)
.orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id)));
}
/** GET /summary -> 台账汇总(待审批数、在途借款总额、已借未核销总额)。 */
@GetMapping("/summary")
public ApiResp<Map<String, Object>> summary(@RequestParam(required = false) String orgUnit) {
List<CrmPettyCash> all = orgUnit != null && !orgUnit.isBlank()
? repo.findByOrgUnit(orgUnit) : repo.findAll();
long pendingApproval = all.stream().filter(c -> "待审批".equals(c.getStatus())).count();
double inTransit = all.stream()
.filter(c -> "已借出".equals(c.getStatus()))
.mapToDouble(c -> Money.nz(c.getPendingAmount()).doubleValue()).sum();
double totalBorrowed = all.stream()
.filter(c -> List.of("已批准", "已借出").contains(c.getStatus()))
.mapToDouble(c -> Money.nz(c.getAmount()).doubleValue()).sum();
long overdueCount = all.stream()
.filter(c -> "已借出".equals(c.getStatus()) && c.getExpectedReturnDate() != null
&& LocalDate.now().isAfter(LocalDate.parse(c.getExpectedReturnDate())))
.count();
return ApiResp.ok(Map.of(
"pendingApproval", pendingApproval,
"inTransitAmount", inTransit,
"totalBorrowedAmount", totalBorrowed,
"overdueCount", overdueCount));
}
public record CreateRequest(
String type, String applicant, String orgUnit, Double amount,
Long opportunityId, Long projectId, String purpose,
String expectedReturnDate, String applyDate) {
}
@PostMapping
public ApiResp<CrmPettyCash> create(@RequestBody CreateRequest req) {
if (req.type() == null || req.type().isBlank()) throw new ApiException(400, "type 不能为空");
if (req.applicant() == null || req.applicant().isBlank()) throw new ApiException(400, "申请人不能为空");
if (req.amount() != null && req.amount() < 0) throw new ApiException(400, "金额不能为负数");
CrmPettyCash c = new CrmPettyCash();
long seq = repo.count() + 1;
c.setCode("BYJJ-" + LocalDate.now().getYear() + "-" + String.format("%04d", seq));
c.setType(req.type());
c.setApplicant(req.applicant());
c.setOrgUnit(req.orgUnit());
c.setAmount(Money.of(req.amount()));
c.setOpportunityId(req.opportunityId());
c.setProjectId(req.projectId());
c.setPurpose(req.purpose());
c.setExpectedReturnDate(req.expectedReturnDate());
c.setApplyDate(req.applyDate() != null ? req.applyDate() : LocalDate.now().toString());
c.setStatus("草稿");
c.setVerifiedAmount(Money.ZERO);
c.setPendingAmount(Money.of(req.amount()));
c.setCreatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
public record UpdateRequest(
String type, String applicant, String orgUnit, Double amount,
Long opportunityId, Long projectId, String purpose,
String expectedReturnDate, String applyDate) {
}
@PatchMapping("/{id}")
public ApiResp<CrmPettyCash> update(@PathVariable Long id, @RequestBody UpdateRequest req) {
CrmPettyCash c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id));
if (!List.of("草稿", "已驳回").contains(c.getStatus())) {
throw new ApiException(409, "仅「草稿」或「已驳回」状态可编辑,当前:" + c.getStatus());
}
if (req.type() != null) c.setType(req.type());
if (req.applicant() != null) c.setApplicant(req.applicant());
if (req.orgUnit() != null) c.setOrgUnit(req.orgUnit());
if (req.amount() != null) {
if (req.amount() < 0) throw new ApiException(400, "金额不能为负数");
c.setAmount(Money.of(req.amount()));
c.setPendingAmount(Money.of(req.amount()));
}
if (req.opportunityId() != null) c.setOpportunityId(req.opportunityId());
if (req.projectId() != null) c.setProjectId(req.projectId());
if (req.purpose() != null) c.setPurpose(req.purpose());
if (req.expectedReturnDate() != null) c.setExpectedReturnDate(req.expectedReturnDate());
if (req.applyDate() != null) c.setApplyDate(req.applyDate());
return ApiResp.ok(repo.save(c));
}
/** POST /{id}/submit -> 草稿/已驳回 → 待审批。 */
@PostMapping("/{id}/submit")
public ApiResp<CrmPettyCash> submit(@PathVariable Long id) {
CrmPettyCash c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id));
if (!List.of("草稿", "已驳回").contains(c.getStatus())) {
throw new ApiException(409, "仅「草稿」或「已驳回」可提交,当前:" + c.getStatus());
}
if (c.getPurpose() == null || c.getPurpose().isBlank()) {
throw new ApiException(400, "请填写用途说明再提交");
}
c.setStatus("待审批");
return ApiResp.ok(repo.save(c));
}
public record ApproveRequest(Boolean approve, String approver, String comment) {
}
/** POST /{id}/approve -> 待审批 → 已批准(approve=true)或 已驳回(approve=false)。 */
@PostMapping("/{id}/approve")
public ApiResp<CrmPettyCash> approve(@PathVariable Long id, @RequestBody ApproveRequest req) {
CrmPettyCash c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id));
if (!"待审批".equals(c.getStatus())) {
throw new ApiException(409, "仅「待审批」状态可审批,当前:" + c.getStatus());
}
boolean pass = req.approve() != null && req.approve();
c.setStatus(pass ? "已批准" : "已驳回");
c.setApprover(req.approver());
c.setApproveComment(req.comment());
c.setApprovedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
/** POST /{id}/disburse -> 已批准 → 已借出(实际拨款)。 */
@PostMapping("/{id}/disburse")
public ApiResp<CrmPettyCash> disburse(@PathVariable Long id) {
CrmPettyCash c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id));
if (!"已批准".equals(c.getStatus())) {
throw new ApiException(409, "仅「已批准」状态可拨款,当前:" + c.getStatus());
}
c.setStatus("已借出");
return ApiResp.ok(repo.save(c));
}
public record VerifyRequest(Double verifiedAmount, String verifyDate, String voucherNote) {
}
/**
* POST /{id}/verify -> 已借出 → 核销(传实际报销额,自动算未核销余额)。
* 若 verifiedAmount == amount,则状态变「已核销」,否则留「核销中」允许多次核销。
*/
@PostMapping("/{id}/verify")
@Transactional
public ApiResp<CrmPettyCash> verify(@PathVariable Long id, @RequestBody VerifyRequest req) {
CrmPettyCash c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id));
if (!List.of("已借出", "核销中").contains(c.getStatus())) {
throw new ApiException(409, "仅「已借出」或「核销中」状态可核销,当前:" + c.getStatus());
}
if (req.verifiedAmount() == null || req.verifiedAmount() < 0) {
throw new ApiException(400, "核销金额不能为空或负数");
}
java.math.BigDecimal addVerify = Money.of(req.verifiedAmount());
java.math.BigDecimal totalVerified = Money.add(Money.nz(c.getVerifiedAmount()), addVerify);
if (Money.gt(totalVerified, Money.nz(c.getAmount()))) {
throw new ApiException(400, "累计核销额 " + totalVerified + " 超过借款额 " + c.getAmount());
}
c.setVerifiedAmount(totalVerified);
c.setPendingAmount(Money.add(Money.nz(c.getAmount()), totalVerified.negate()));
if (req.verifyDate() != null) c.setVerifyDate(req.verifyDate());
if (req.voucherNote() != null) c.setVoucherNote(req.voucherNote());
boolean fullyVerified = !Money.gt(c.getPendingAmount(), java.math.BigDecimal.ZERO);
c.setStatus(fullyVerified ? "已核销" : "核销中");
return ApiResp.ok(repo.save(c));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
CrmPettyCash c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id));
if (List.of("已借出", "已核销").contains(c.getStatus())) {
throw new ApiException(409, "已借出或已核销记录不可删除");
}
repo.deleteById(id);
return ApiResp.ok(null);
}
}