package com.autovoucher.service; import com.autovoucher.common.ApiException; import com.autovoucher.config.TenantContext; import com.autovoucher.domain.Book; import com.autovoucher.domain.SourceDoc; import com.autovoucher.domain.SourceLine; import com.autovoucher.domain.Transaction; import com.autovoucher.domain.TransactionLine; import com.autovoucher.domain.Voucher; import com.autovoucher.domain.VoucherLine; import com.autovoucher.domain.VoucherType; import com.autovoucher.repository.BookRepository; import com.autovoucher.repository.SourceDocRepository; import com.autovoucher.repository.SourceLineRepository; import com.autovoucher.repository.TransactionLineRepository; import com.autovoucher.repository.TransactionRepository; import com.autovoucher.repository.VoucherLineRepository; import com.autovoucher.repository.VoucherRepository; import com.autovoucher.repository.VoucherTypeRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; @Service public class DocumentProcessingService { public static final String RECEIVED = "RECEIVED"; public static final String IGNORED = "IGNORED"; public static final String MANUAL_REQUIRED = "MANUAL_REQUIRED"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; private final SourceDocRepository sourceDocRepository; private final SourceLineRepository sourceLineRepository; private final VoucherRepository voucherRepository; private final VoucherLineRepository voucherLineRepository; private final VoucherTypeRepository voucherTypeRepository; private final TransactionRepository transactionRepository; private final TransactionLineRepository transactionLineRepository; private final BookRepository bookRepository; private final VoucherSuggestionService suggestionService; private final VoucherNumberService voucherNumberService; private final AuditService auditService; private final ObjectMapper objectMapper; public DocumentProcessingService(SourceDocRepository sourceDocRepository, SourceLineRepository sourceLineRepository, VoucherRepository voucherRepository, VoucherLineRepository voucherLineRepository, VoucherTypeRepository voucherTypeRepository, TransactionRepository transactionRepository, TransactionLineRepository transactionLineRepository, BookRepository bookRepository, VoucherSuggestionService suggestionService, VoucherNumberService voucherNumberService, AuditService auditService, ObjectMapper objectMapper) { this.sourceDocRepository = sourceDocRepository; this.sourceLineRepository = sourceLineRepository; this.voucherRepository = voucherRepository; this.voucherLineRepository = voucherLineRepository; this.voucherTypeRepository = voucherTypeRepository; this.transactionRepository = transactionRepository; this.transactionLineRepository = transactionLineRepository; this.bookRepository = bookRepository; this.suggestionService = suggestionService; this.voucherNumberService = voucherNumberService; this.auditService = auditService; this.objectMapper = objectMapper; } @Transactional public ProcessingOutcome process(SourceDoc requestedDoc, boolean forceReprocess) { Long tenantId = TenantContext.getTenantId(); // Reprocess passes a document loaded outside this transaction; with // open-in-view disabled it is detached, so re-attach it before mutating // its processing status. SourceDoc doc = sourceDocRepository.findById(requestedDoc.getId()) .filter(d -> tenantId.equals(d.getTenantId())) .orElseThrow(() -> ApiException.notFound("SOURCE_DOC_NOT_FOUND", "源单据不存在: " + requestedDoc.getId())); if (doc.getDocType() != null && doc.getDocType().startsWith("PAYMENT_")) { doc.setProcessingStatus(IGNORED); doc.setManualReason("PAYMENT_DOCUMENT_HANDLED_IN_FINANCE_SYSTEM"); return ProcessingOutcome.ignored("付款单据由财务系统处理,不生成费用凭证"); } var current = voucherRepository.findByTenantIdAndSourceDocIdAndCurrentFlag(tenantId, doc.getId(), 1); if (current.isPresent() && !forceReprocess) { Voucher voucher = current.get(); return new ProcessingOutcome("DUPLICATE_IGNORED", voucher.getId(), "当前单据已有有效凭证: " + voucher.getVoucherNo(), List.of()); } if (current.isPresent()) { Voucher existing = current.get(); if (List.of("APPROVED", "PUSHED", "SUCCESS", "SUCCESS_WITH_EXISTING").contains(existing.getStatus())) { throw ApiException.conflict("VOUCHER_ALREADY_APPROVED", "凭证 " + existing.getVoucherNo() + " 已审核或已推送,不能重新生成"); } existing.setCurrentFlag(null); existing.setStatus("SUPERSEDED"); voucherRepository.save(existing); } List sourceLines = sourceLineRepository .findAllByTenantIdAndSourceDocIdOrderByLineNoAsc(tenantId, doc.getId()); if (sourceLines.isEmpty()) { doc.setProcessingStatus(MANUAL_REQUIRED); doc.setManualReason("NO_SOURCE_LINES"); return new ProcessingOutcome(MANUAL_REQUIRED, null, "单据没有费用明细", List.of()); } Book book = bookRepository.findByTenantIdAndBookId(tenantId, doc.getBookId()) .orElseThrow(() -> ApiException.badRequest("BOOK_NOT_FOUND", "账簿不存在: " + doc.getBookId())); Transaction transaction = rebuildTransaction(doc, sourceLines); SuggestionOutcome suggestion = suggestionService.suggest(doc, sourceLines); if (suggestion.manualRequired()) { transaction.setStatus(MANUAL_REQUIRED); transactionRepository.save(transaction); doc.setProcessingStatus(MANUAL_REQUIRED); doc.setManualReason(suggestion.manualReason()); doc.setCandidatesJson(toJson(suggestion.candidates())); auditService.log("VOUCHER_MANUAL_REQUIRED", "SOURCE_DOC", doc.getId(), "{\"reason\":\"" + suggestion.manualReason() + "\"}"); return new ProcessingOutcome(MANUAL_REQUIRED, null, suggestion.manualReason(), suggestion.candidates()); } int version = voucherRepository .findAllByTenantIdAndSourceDocIdOrderByIdDesc(tenantId, doc.getId()).size() + 1; VoucherType voucherType = voucherTypeRepository.findByTenantIdAndCode(tenantId, "JZ") .or(() -> voucherTypeRepository.findAllByTenantIdOrderByIdAsc(tenantId).stream().findFirst()) .orElseThrow(() -> ApiException.badRequest("VOUCHER_TYPE_NOT_FOUND", "未配置凭证类型")); Voucher voucher = new Voucher(); voucher.setTenantId(tenantId); voucher.setVoucherNo(voucherNumberService.next(doc, book, version)); voucher.setSourceDocId(doc.getId()); voucher.setTransactionId(transaction.getId()); voucher.setVoucherTypeCode(voucherType.getCode()); voucher.setCompanyId(doc.getCompanyId()); voucher.setBookId(doc.getBookId()); voucher.setVoucherDate(doc.getApprovedAt() == null ? LocalDate.now() : doc.getApprovedAt().toLocalDate()); voucher.setPeriod(book.getCurrentPeriod()); voucher.setCurrency(doc.getCurrency() == null ? "CNY" : doc.getCurrency()); voucher.setStatus(PENDING_REVIEW); voucher.setVersion(version); voucher.setCurrentFlag(1); voucher.setConfidence(suggestion.confidence()); voucher.setDecisionTraceJson(suggestion.traceJson()); voucher.setTargetSystem(book.getTargetSystem()); voucherRepository.save(voucher); BigDecimal debit = BigDecimal.ZERO; BigDecimal credit = BigDecimal.ZERO; int lineNo = 1; for (SuggestedLine suggested : suggestion.lines()) { VoucherLine line = new VoucherLine(); line.setTenantId(tenantId); line.setVoucherId(voucher.getId()); line.setLineNo(lineNo++); line.setDirection(suggested.direction()); line.setAccountCode(suggested.accountCode()); line.setAccountName(suggested.accountName()); line.setAccountGroup(suggested.accountGroup()); line.setAmount(suggested.amount()); line.setCurrency(voucher.getCurrency()); line.setTaxRate(suggested.taxRate()); line.setTaxAmount(suggested.taxAmount()); line.setAuxJson(toJson(suggested.aux())); line.setSummary(suggested.summary()); line.setDecisionSource(suggested.decisionSource()); line.setConfidence(suggested.confidence()); line.setEvidenceJson(suggested.evidenceJson()); voucherLineRepository.save(line); if ("DEBIT".equals(suggested.direction())) { debit = debit.add(suggested.amount()); } else { credit = credit.add(suggested.amount()); } } voucher.setTotalDebit(debit); voucher.setTotalCredit(credit); voucherRepository.save(voucher); transaction.setStatus("GENERATED"); transactionRepository.save(transaction); doc.setProcessingStatus(PENDING_REVIEW); doc.setManualReason(null); doc.setCandidatesJson(null); auditService.log("VOUCHER_GENERATED", "VOUCHER", voucher.getId(), "{\"voucherNo\":\"" + voucher.getVoucherNo() + "\",\"sourceDocId\":" + doc.getId() + "}"); return new ProcessingOutcome(PENDING_REVIEW, voucher.getId(), "已生成凭证建议 " + voucher.getVoucherNo(), List.of()); } private Transaction rebuildTransaction(SourceDoc doc, List lines) { Long tenantId = TenantContext.getTenantId(); transactionRepository.findByTenantIdAndSourceDocId(tenantId, doc.getId()).ifPresent(existing -> { transactionLineRepository.deleteAllByTenantIdAndTransactionId(tenantId, existing.getId()); transactionRepository.delete(existing); }); Transaction transaction = new Transaction(); transaction.setTenantId(tenantId); transaction.setSourceDocId(doc.getId()); transaction.setBusinessScene(doc.getBusinessScene()); transaction.setIsBusinessTrip(doc.getIsBusinessTrip()); transaction.setTripId(doc.getTripId()); transaction.setDepartmentId(doc.getDepartmentId()); transaction.setApplicantId(doc.getApplicantId()); transaction.setTotalAmount(doc.getTotalAmount()); transaction.setStatus("EXTRACTED"); transactionRepository.save(transaction); for (SourceLine sourceLine : lines) { TransactionLine line = new TransactionLine(); line.setTenantId(tenantId); line.setTransactionId(transaction.getId()); line.setLineNo(sourceLine.getLineNo()); line.setExpenseCategory(sourceLine.getExpenseCategory()); line.setAmount(sourceLine.getAmount()); line.setAmountExcludingTax(sourceLine.getAmountExcludingTax()); line.setTaxAmount(sourceLine.getTaxAmount()); line.setInvoiceType(sourceLine.getInvoiceType()); line.setProjectId(sourceLine.getProjectId()); line.setCostCenterId(sourceLine.getCostCenterId()); line.setMerchantId(sourceLine.getMerchantId()); line.setCustomerId(sourceLine.getCustomerId()); transactionLineRepository.save(line); } return transaction; } private String toJson(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { return "[]"; } } }