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