You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

253 lines
12 KiB

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<SourceLine> 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<SourceLine> 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 "[]";
}
}
}