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,433 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user