Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FinProcurProdVoucherController.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

434 lines
19 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.domain.ArApItem;
import com.kaidi.oa.domain.Voucher;
import com.kaidi.oa.repository.ArApItemRepository;
import com.kaidi.oa.repository.VoucherRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
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.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 财务部·采购/生产侧自动凭证生成联动(模块1缺口补全)。
*
* 审计缺口:
* 业务模块(采购/销售/生产)自动触发凭证生成联动仅部分落地
* FinArApAutoController 有收款联动,但生产/采购侧无全自动)。
*
* 本控制器补全:
* POST /procure-receipt-voucher —— 采购入库自动生成凭证(借 原材料/库存商品 贷 应付账款)。
* POST /procure-payment-voucher —— 采购付款自动生成凭证(借 应付账款 贷 银行存款)。
* POST /mfg-wip-voucher —— 生产领料自动生成凭证(借 生产成本-直接材料 贷 原材料)。
* POST /mfg-completion-voucher —— 完工入库自动生成凭证(借 库存商品 贷 生产成本)。
* POST /sales-delivery-voucher —— 销售发货自动生成凭证(借 应收账款 贷 主营业务收入/应交税费)。
* GET /auto-voucher-status —— 查询各模块自动凭证生成状态(已生成 vs 待生成)。
*
* 所有凭证自动设置 status=已过账(S_POSTED),sourceType 标识来源业务模块。
*
* 写口:AuthInterceptor FINANCE_PREFIXES(/api/oa/fin-procur-prod-voucher) 限 ADMIN/APPROVER。
*/
@RestController
@RequestMapping("/api/oa/fin-procur-prod-voucher")
public class FinProcurProdVoucherController {
private final VoucherRepository voucherRepo;
private final ArApItemRepository arApRepo;
public FinProcurProdVoucherController(VoucherRepository voucherRepo,
ArApItemRepository arApRepo) {
this.voucherRepo = voucherRepo;
this.arApRepo = arApRepo;
}
// ============================================================
// 1. 采购入库凭证(原材料/库存商品 入库)
// ============================================================
public record ProcureReceiptRequest(
String poNo,
String supplierName,
String materialName,
Double quantity,
Double unitPrice,
Double amount,
String vatRate,
String preparer
) {}
/**
* 采购入库自动生成会计凭证:
* 借:原材料(或库存商品)
* 借:应交税费-进项税额(若 VAT 专票)
* 贷:应付账款
*/
@PostMapping("/procure-receipt-voucher")
@Transactional
public ApiResp<Map<String, Object>> procureReceiptVoucher(@RequestBody ProcureReceiptRequest req) {
if (req.poNo() == null || req.poNo().isBlank()) throw new ApiException(400, "采购单号(poNo)不能为空");
if (req.amount() == null || req.amount() <= 0) throw new ApiException(400, "金额(amount)须大于 0");
String voucherNo = "PO-RCP-" + req.poNo().replace("/", "-");
boolean dup = voucherRepo.findAll().stream().anyMatch(v -> voucherNo.equals(v.getVoucherNo()));
if (dup) throw new ApiException(409, "采购入库凭证已生成(" + voucherNo + "),不可重复");
BigDecimal amt = Money.of(req.amount());
double vatRatePct = parseVatRate(req.vatRate());
BigDecimal vatAmt = BigDecimal.ZERO;
BigDecimal netAmt = amt;
if (vatRatePct > 0) {
vatAmt = amt.multiply(BigDecimal.valueOf(vatRatePct / (1 + vatRatePct)))
.setScale(2, RoundingMode.HALF_UP);
netAmt = amt.subtract(vatAmt);
}
// 主凭证:原材料/贷应付
Voucher v = buildVoucher(voucherNo,
"1201 原材料(" + (req.materialName() != null ? req.materialName() : "") + "",
"2202 应付账款-" + (req.supplierName() != null ? req.supplierName() : ""),
netAmt,
"采购入库:" + req.poNo() + (req.supplierName() != null ? " / " + req.supplierName() : ""),
"procure-receipt", req.preparer());
Voucher savedV = voucherRepo.save(v);
// 进项税凭证(如果 VAT 专票)
Voucher vatV = null;
if (vatAmt.compareTo(BigDecimal.ZERO) > 0) {
String vatVno = "PO-VAT-" + req.poNo().replace("/", "-");
vatV = buildVoucher(vatVno,
"2221 应交税费-进项税额",
"2202 应付账款-" + (req.supplierName() != null ? req.supplierName() : ""),
vatAmt,
"采购进项税额:" + req.poNo() + " 税率" + req.vatRate(),
"procure-vat", req.preparer());
vatV = voucherRepo.save(vatV);
}
// 自动创建应付单(ArApItem
ArApItem ap = new ArApItem();
ap.setCode("AP-PO-" + req.poNo().replace("/", "-"));
ap.setArApType(ArApItem.T_AP);
ap.setPartyName(req.supplierName());
ap.setRelatedRef(req.poNo());
ap.setAmount(amt);
ap.setWrittenOff(BigDecimal.ZERO);
ap.setUnwrittenOff(amt);
ap.setStatus(ArApItem.S_OPEN);
ap.setRemark("[CERT:未认证] 采购入库自动生成应付单");
ap.setCreatedAt(Instant.now());
ArApItem savedAp = arApRepo.save(ap);
Map<String, Object> result = new LinkedHashMap<>();
result.put("voucherId", savedV.getId());
result.put("voucherNo", savedV.getVoucherNo());
result.put("netAmount", netAmt);
result.put("vatAmount", vatAmt);
result.put("totalAmount", amt);
if (vatV != null) {
result.put("vatVoucherId", vatV.getId());
result.put("vatVoucherNo", vatV.getVoucherNo());
}
result.put("apItemId", savedAp.getId());
result.put("message", "采购入库凭证已自动生成,同步创建应付单 #" + savedAp.getId());
return ApiResp.ok(result);
}
// ============================================================
// 2. 采购付款凭证
// ============================================================
public record ProcurePaymentRequest(
String apCode,
Double paidAmount,
String paymentRef,
String preparer
) {}
@PostMapping("/procure-payment-voucher")
@Transactional
public ApiResp<Map<String, Object>> procurePaymentVoucher(@RequestBody ProcurePaymentRequest req) {
if (req.paidAmount() == null || req.paidAmount() <= 0) throw new ApiException(400, "付款金额须大于 0");
BigDecimal paid = Money.of(req.paidAmount());
String voucherNo = "PO-PAY-" + (req.paymentRef() != null ? req.paymentRef() : System.currentTimeMillis() % 100000);
Voucher v = buildVoucher(voucherNo,
"2202 应付账款",
"1002 银行存款",
paid,
"采购付款:" + (req.apCode() != null ? req.apCode() : "") + (req.paymentRef() != null ? " REF:" + req.paymentRef() : ""),
"procure-payment", req.preparer());
Voucher saved = voucherRepo.save(v);
Map<String, Object> result = new LinkedHashMap<>();
result.put("voucherId", saved.getId());
result.put("voucherNo", saved.getVoucherNo());
result.put("paidAmount", paid);
result.put("message", "采购付款凭证已自动生成");
return ApiResp.ok(result);
}
// ============================================================
// 3. 生产领料凭证(WIP 领料)
// ============================================================
public record MfgWipRequest(
String woNo,
String productName,
Double materialCost,
String preparer
) {}
@PostMapping("/mfg-wip-voucher")
@Transactional
public ApiResp<Map<String, Object>> mfgWipVoucher(@RequestBody MfgWipRequest req) {
if (req.woNo() == null || req.woNo().isBlank()) throw new ApiException(400, "工单号(woNo)不能为空");
if (req.materialCost() == null || req.materialCost() <= 0) throw new ApiException(400, "领料成本须大于 0");
BigDecimal cost = Money.of(req.materialCost());
String voucherNo = "WIP-MAT-" + req.woNo().replace("/", "-");
Voucher v = buildVoucher(voucherNo,
"1411 生产成本-直接材料(" + (req.productName() != null ? req.productName() : "") + "",
"1201 原材料",
cost,
"生产领料:" + req.woNo() + (req.productName() != null ? " / " + req.productName() : ""),
"mfg-wip-material", req.preparer());
Voucher saved = voucherRepo.save(v);
Map<String, Object> result = new LinkedHashMap<>();
result.put("voucherId", saved.getId());
result.put("voucherNo", saved.getVoucherNo());
result.put("materialCost", cost);
result.put("message", "生产领料凭证已自动生成(借 生产成本 贷 原材料)");
return ApiResp.ok(result);
}
// ============================================================
// 4. 完工入库凭证
// ============================================================
public record MfgCompletionRequest(
String woNo,
String productName,
Double totalProductionCost,
String preparer
) {}
@PostMapping("/mfg-completion-voucher")
@Transactional
public ApiResp<Map<String, Object>> mfgCompletionVoucher(@RequestBody MfgCompletionRequest req) {
if (req.woNo() == null || req.woNo().isBlank()) throw new ApiException(400, "工单号(woNo)不能为空");
if (req.totalProductionCost() == null || req.totalProductionCost() <= 0) {
throw new ApiException(400, "完工成本须大于 0");
}
BigDecimal cost = Money.of(req.totalProductionCost());
String voucherNo = "MFG-FIN-" + req.woNo().replace("/", "-");
Voucher v = buildVoucher(voucherNo,
"1405 库存商品(" + (req.productName() != null ? req.productName() : "") + "",
"1411 生产成本",
cost,
"生产完工入库:" + req.woNo() + (req.productName() != null ? " / " + req.productName() : ""),
"mfg-completion", req.preparer());
Voucher saved = voucherRepo.save(v);
Map<String, Object> result = new LinkedHashMap<>();
result.put("voucherId", saved.getId());
result.put("voucherNo", saved.getVoucherNo());
result.put("completionCost", cost);
result.put("message", "完工入库凭证已自动生成(借 库存商品 贷 生产成本)");
return ApiResp.ok(result);
}
// ============================================================
// 5. 销售发货凭证
// ============================================================
public record SalesDeliveryRequest(
String soNo,
String customerName,
Double saleAmount,
Double costOfGoods,
String vatRate,
String preparer
) {}
/**
* 销售发货自动生成双分录:
* ① 借 应收账款 / 贷 主营业务收入 + 应交税费-销项税额(含税)
* ② 借 主营业务成本 / 贷 库存商品(销售成本结转)
*/
@PostMapping("/sales-delivery-voucher")
@Transactional
public ApiResp<Map<String, Object>> salesDeliveryVoucher(@RequestBody SalesDeliveryRequest req) {
if (req.soNo() == null || req.soNo().isBlank()) throw new ApiException(400, "销售单号(soNo)不能为空");
if (req.saleAmount() == null || req.saleAmount() <= 0) throw new ApiException(400, "销售金额须大于 0");
BigDecimal saleAmt = Money.of(req.saleAmount());
double vatRatePct = parseVatRate(req.vatRate());
BigDecimal vatAmt = saleAmt.multiply(BigDecimal.valueOf(vatRatePct / (1 + vatRatePct)))
.setScale(2, RoundingMode.HALF_UP);
BigDecimal revenueAmt = saleAmt.subtract(vatAmt);
String salesVno = "SALE-DLV-" + req.soNo().replace("/", "-");
// 收入凭证(简化:单借贷;实际系统可拆多行凭证)
Voucher saleV = buildVoucher(salesVno,
"1122 应收账款-" + (req.customerName() != null ? req.customerName() : ""),
"6001 主营业务收入 + 2221 应交税费-销项税额",
saleAmt,
"销售发货:" + req.soNo() + (req.customerName() != null ? " / " + req.customerName() : "") + " 含税 " + saleAmt,
"sales-delivery-income", req.preparer());
saleV.setCreditAccount("6001 主营业务收入【" + revenueAmt + "】+ 2221 销项税额【" + vatAmt + "】");
Voucher savedSaleV = voucherRepo.save(saleV);
// 自动创建应收单
ArApItem ar = new ArApItem();
ar.setCode("AR-SO-" + req.soNo().replace("/", "-"));
ar.setArApType(ArApItem.T_AR);
ar.setPartyName(req.customerName());
ar.setRelatedRef(req.soNo());
ar.setAmount(saleAmt);
ar.setWrittenOff(BigDecimal.ZERO);
ar.setUnwrittenOff(saleAmt);
ar.setStatus(ArApItem.S_OPEN);
ar.setRemark("销售发货自动生成应收单 | VAT:" + vatAmt);
ar.setCreatedAt(Instant.now());
ArApItem savedAr = arApRepo.save(ar);
Map<String, Object> result = new LinkedHashMap<>();
result.put("salesVoucherId", savedSaleV.getId());
result.put("salesVoucherNo", savedSaleV.getVoucherNo());
result.put("saleAmount", saleAmt);
result.put("revenueAmount", revenueAmt);
result.put("vatAmount", vatAmt);
result.put("arItemId", savedAr.getId());
// 成本结转凭证(可选,有 costOfGoods 时生成)
if (req.costOfGoods() != null && req.costOfGoods() > 0) {
BigDecimal cog = Money.of(req.costOfGoods());
String cogVno = "SALE-COG-" + req.soNo().replace("/", "-");
Voucher cogV = buildVoucher(cogVno,
"6401 主营业务成本",
"1405 库存商品",
cog,
"销售成本结转:" + req.soNo() + " 成本 " + cog + " 元",
"sales-cog", req.preparer());
Voucher savedCogV = voucherRepo.save(cogV);
result.put("cogVoucherId", savedCogV.getId());
result.put("cogVoucherNo", savedCogV.getVoucherNo());
result.put("costOfGoods", cog);
}
result.put("message", "销售发货凭证已自动生成,应收单 #" + savedAr.getId() + " 已同步创建");
return ApiResp.ok(result);
}
// ============================================================
// 6. 自动凭证生成状态汇总
// ============================================================
@GetMapping("/auto-voucher-status")
public ApiResp<Map<String, Object>> autoVoucherStatus(
@RequestParam(required = false) String period) {
List<Voucher> all = voucherRepo.findAll();
if (period != null && !period.isBlank()) {
all = all.stream()
.filter(v -> v.getVoucherDate() != null && v.getVoucherDate().startsWith(period))
.toList();
}
Map<String, Long> bySourceType = new LinkedHashMap<>();
for (Voucher v : all) {
String st = v.getSourceType() != null ? v.getSourceType() : "手工录入";
bySourceType.merge(st, 1L, Long::sum);
}
long autoGenCount = all.stream()
.filter(v -> v.getSourceType() != null && !v.getSourceType().isEmpty())
.count();
long manualCount = all.stream()
.filter(v -> v.getSourceType() == null || v.getSourceType().isEmpty())
.count();
List<Map<String, Object>> sourceRows = new ArrayList<>();
for (Map.Entry<String, Long> en : bySourceType.entrySet()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("sourceType", en.getKey());
row.put("count", en.getValue());
row.put("autoGenerated", !"手工录入".equals(en.getKey()));
sourceRows.add(row);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("period", period != null ? period : "全部");
result.put("totalVouchers", all.size());
result.put("autoGeneratedCount", autoGenCount);
result.put("manualCount", manualCount);
result.put("autoRate", all.size() > 0
? BigDecimal.valueOf(autoGenCount * 100.0 / all.size())
.setScale(1, RoundingMode.HALF_UP).toString() + "%"
: "N/A");
result.put("bySourceType", sourceRows);
result.put("supportedAutoSources", List.of(
"ar-writeoff(收款核销)",
"ap-writeoff(付款核销)",
"fx-adjust(外币调汇)",
"transfer-profit(结转利润)",
"accrue-income-tax(计提所得税)",
"procure-receipt(采购入库)",
"procure-payment(采购付款)",
"mfg-wip-material(生产领料)",
"mfg-completion(完工入库)",
"sales-delivery-income(销售收入)",
"sales-cog(成本结转)",
"bad-debt-provision(坏账计提)"
));
return ApiResp.ok(result);
}
// ============================================================
// helpers
// ============================================================
private Voucher buildVoucher(String voucherNo, String debit, String credit,
BigDecimal amount, String summary, String sourceType, String preparer) {
Voucher v = new Voucher();
v.setVoucherNo(voucherNo);
v.setVoucherDate(LocalDate.now().toString());
v.setSummary(summary);
v.setDebitAccount(debit);
v.setCreditAccount(credit);
v.setAmount(amount);
v.setStatus(Voucher.S_POSTED);
v.setPreparer(preparer != null ? preparer : "系统自动");
v.setIsReversal(false);
v.setReversed(false);
v.setSourceType(sourceType);
v.setCreatedAt(Instant.now());
return v;
}
private double parseVatRate(String vatRate) {
if (vatRate == null || vatRate.isBlank()) return 0.13; // 默认13%
try {
String cleaned = vatRate.replace("%", "").trim();
double r = Double.parseDouble(cleaned);
return r > 1 ? r / 100.0 : r; // 支持 "13" 或 "0.13"
} catch (NumberFormatException e) {
return 0.13;
}
}
}