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,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;
}
}
}