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> 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 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> 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 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> 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 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> 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 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> 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 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> autoVoucherStatus( @RequestParam(required = false) String period) { List all = voucherRepo.findAll(); if (period != null && !period.isBlank()) { all = all.stream() .filter(v -> v.getVoucherDate() != null && v.getVoucherDate().startsWith(period)) .toList(); } Map 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> sourceRows = new ArrayList<>(); for (Map.Entry en : bySourceType.entrySet()) { Map row = new LinkedHashMap<>(); row.put("sourceType", en.getKey()); row.put("count", en.getValue()); row.put("autoGenerated", !"手工录入".equals(en.getKey())); sourceRows.add(row); } Map 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; } } }