594 lines
38 KiB
Java
594 lines
38 KiB
Java
package com.baiyue.finance.service;
|
|
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction;
|
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.slf4j.MDC;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
|
import org.springframework.jdbc.support.KeyHolder;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.util.StringUtils;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.security.MessageDigest;
|
|
import java.sql.Date;
|
|
import java.sql.PreparedStatement;
|
|
import java.sql.Statement;
|
|
import java.sql.Timestamp;
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.util.ArrayList;
|
|
import java.util.HashSet;
|
|
import java.util.HexFormat;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.UUID;
|
|
|
|
@Service
|
|
public class FinanceSettlementService {
|
|
private static final String CNY = "CNY";
|
|
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
|
private final JdbcTemplate jdbc;
|
|
private final ObjectMapper objectMapper;
|
|
private final FinanceVoucherValidator voucherValidator;
|
|
private final FinanceSupplierValidator supplierValidator;
|
|
|
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
|
FinanceSupplierValidator supplierValidator) {
|
|
this.jdbc = jdbc;
|
|
this.objectMapper = objectMapper;
|
|
this.voucherValidator = voucherValidator;
|
|
this.supplierValidator = supplierValidator;
|
|
}
|
|
|
|
@Transactional
|
|
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
|
CreateReceivableSettlementRequest request) {
|
|
if (request == null || request.reconciliationStatementId() <= 0) {
|
|
throw error("FIN_400", "对账单ID无效");
|
|
}
|
|
requireCny(request.currency());
|
|
Map<String, Object> bill = one(
|
|
"SELECT id,tenant_id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount " +
|
|
"FROM fin_bill WHERE id=? FOR UPDATE",
|
|
"对账单不存在", request.reconciliationStatementId());
|
|
long customerId = number(bill, "tenant_id").longValue();
|
|
if (!CNY.equals(text(bill, "currency_code"))) throw error("FIN_400", "对账单币种必须为CNY");
|
|
if (!"ISSUED".equals(text(bill, "status"))) throw error("FIN_409", "只有已出账对账单可生成应收结算单");
|
|
BigDecimal total = decimal(bill, "total_amount");
|
|
if (total.signum() <= 0) throw error("FIN_400", "应收结算金额必须大于0");
|
|
Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST",
|
|
"/api/platform/finance/v1/receivable-settlements", request);
|
|
if (replay != null) return getSettlement(replay);
|
|
Integer occupied = jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " +
|
|
"AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class,
|
|
customerId, request.reconciliationStatementId());
|
|
if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单");
|
|
Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
|
|
Integer.class, customerId, request.reconciliationStatementId());
|
|
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
|
|
|
|
LocalDate generatedDate = LocalDate.now();
|
|
long settlementId = insertSettlement(customerId, documentNo("AR"), "RECEIVABLE",
|
|
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
|
|
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
|
|
generatedDate, date(bill, "due_date"), operatorId);
|
|
int inserted = jdbc.update(
|
|
"INSERT INTO fin_settlement_line(tenant_id,settlement_id,fee_source_id,amount,currency_code,created_at) " +
|
|
"SELECT bl.tenant_id,?,f.id,bl.amount,bl.currency_code,CURRENT_TIMESTAMP(3) FROM fin_bill_line bl " +
|
|
"JOIN fin_fee_source f ON f.customer_id=bl.tenant_id AND f.id=bl.source_id " +
|
|
"WHERE bl.tenant_id=? AND bl.bill_id=?",
|
|
settlementId, customerId, request.reconciliationStatementId());
|
|
if (inserted != lineCount) throw error("FIN_409", "对账单费用来源快照不完整");
|
|
audit(customerId, settlementId, "CREATE", null, null, settlementSnapshot(total, BigDecimal.ZERO), operatorId);
|
|
complete(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, settlementId);
|
|
return getSettlement(settlementId);
|
|
}
|
|
|
|
@Transactional
|
|
public SettlementDetail createPayableSettlement(long operatorId, String key,
|
|
CreatePayableSettlementRequest request) {
|
|
validatePayableRequest(request);
|
|
long customerId = request.customerId();
|
|
Long replay = reserve(customerId, operatorId, "SETTLEMENT_AP_CREATE", key, "POST",
|
|
"/api/platform/finance/v1/payable-settlements", request);
|
|
if (replay != null) return getSettlement(replay);
|
|
List<Long> ids = request.feeSourceIds().stream().distinct().sorted().toList();
|
|
String placeholders = String.join(",", java.util.Collections.nCopies(ids.size(), "?"));
|
|
List<Object> args = new ArrayList<>();
|
|
args.addAll(ids);
|
|
List<Map<String, Object>> sources = jdbc.queryForList(
|
|
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
|
|
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus')) review_status," +
|
|
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded')) settlement_included," +
|
|
"CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId'))," +
|
|
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerId'))) AS UNSIGNED) supplier_id," +
|
|
"COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName'))," +
|
|
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerName'))) supplier_name " +
|
|
"FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray());
|
|
if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) {
|
|
throw error("FIN_404", "费用来源不存在或不属于客户");
|
|
}
|
|
for (Map<String, Object> source : sources) {
|
|
Long supplierId = nullableNumber(source, "supplier_id");
|
|
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
|
|
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|
|
|| !"APPROVED".equals(text(source, "review_status")) || !truth(source.get("settlement_included"))) {
|
|
throw error("FIN_409", "费用来源未审核、未参与结算或状态不可用");
|
|
}
|
|
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
|
|
Integer used = jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " +
|
|
"WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue());
|
|
if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用");
|
|
}
|
|
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
|
|
Set<String> supplierCategories = sources.stream()
|
|
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
|
|
.collect(java.util.stream.Collectors.toSet());
|
|
if (supplierCategories.size() != 1) throw error("FIN_409", "应付结算费用来源必须属于同一供应商类别");
|
|
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategories.iterator().next()).name();
|
|
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
|
|
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
|
|
LocalDate generatedDate = LocalDate.now();
|
|
long settlementId = insertSettlement(customerId, documentNo("AP"), "PAYABLE", customerName,
|
|
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
|
|
CNY, total, generatedDate, request.dueDate(), operatorId);
|
|
for (Map<String, Object> source : sources) {
|
|
jdbc.update("INSERT INTO fin_settlement_line(tenant_id,settlement_id,fee_source_id,amount,currency_code,created_at) " +
|
|
"VALUES(?,?,?,?,?,CURRENT_TIMESTAMP(3))", customerId, settlementId,
|
|
number(source, "id").longValue(), decimal(source, "amount"), CNY);
|
|
}
|
|
audit(customerId, settlementId, "CREATE", null, null, settlementSnapshot(total, BigDecimal.ZERO), operatorId);
|
|
complete(customerId, operatorId, "SETTLEMENT_AP_CREATE", key, settlementId);
|
|
return getSettlement(settlementId);
|
|
}
|
|
|
|
public SettlementDetail getSettlement(long settlementId) {
|
|
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
|
Map<String, Object> row = one(
|
|
"SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id,supplier_name_snapshot," +
|
|
"reconciliation_statement_id,business_period,currency_code,total_amount,paid_amount,outstanding_amount," +
|
|
"generated_date,due_date,payment_status,status,version FROM fin_settlement WHERE id=?",
|
|
"结算单不存在", settlementId);
|
|
SettlementSummary summary = summary(row);
|
|
List<SettlementLine> lines = jdbc.queryForList(
|
|
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
|
"f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
|
|
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
|
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
|
.stream().map(source -> line(source, summary)).toList();
|
|
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
|
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
|
|
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
|
|
summary.customerId(), settlementId).stream().map(this::transaction).toList());
|
|
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
|
transactions.addAll(jdbc.queryForList(
|
|
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
|
|
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
|
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
|
|
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
|
|
}
|
|
List<Map<String, Object>> audits = jdbc.queryForList(
|
|
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
|
|
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
|
|
summary.customerId(), settlementId);
|
|
List<SettlementRevision> revisions = new ArrayList<>();
|
|
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
|
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
|
}
|
|
|
|
public List<SettlementSummary> listSettlements(String direction, Long customerId) {
|
|
if (!Set.of("RECEIVABLE", "PAYABLE").contains(direction)) {
|
|
throw error("FIN_400", "结算单方向无效");
|
|
}
|
|
if (customerId != null && customerId <= 0) {
|
|
throw error("FIN_400", "客户范围无效");
|
|
}
|
|
String sql = "SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," +
|
|
"supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," +
|
|
"paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version " +
|
|
"FROM fin_settlement WHERE direction=?";
|
|
List<Object> args = new ArrayList<>();
|
|
args.add(direction);
|
|
if (customerId != null) {
|
|
sql += " AND tenant_id=?";
|
|
args.add(customerId);
|
|
}
|
|
sql += " ORDER BY generated_date DESC,id DESC";
|
|
return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList();
|
|
}
|
|
|
|
@Transactional
|
|
public SettlementDetail createSettlementPayment(long settlementId, long operatorId, String key,
|
|
CreateSettlementPaymentRequest request) {
|
|
validatePaymentRequest(request);
|
|
Map<String, Object> settlement = one(
|
|
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version,supplier_id " +
|
|
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
|
|
long customerId = number(settlement, "tenant_id").longValue();
|
|
Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST",
|
|
"/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request);
|
|
if (replay != null) return getSettlement(replay);
|
|
if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "仅应付结算单可登记付款");
|
|
if (!List.of("ISSUED", "PARTIALLY_PAID").contains(text(settlement, "status"))) {
|
|
throw error("FIN_409", "结算单状态不允许付款");
|
|
}
|
|
if (!CNY.equals(text(settlement, "currency_code"))) throw error("FIN_400", "结算单币种必须为CNY");
|
|
if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突");
|
|
BigDecimal outstanding = decimal(settlement, "outstanding_amount");
|
|
if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额");
|
|
String supplierCategory = payableSupplierCategory(customerId, settlementId);
|
|
supplierValidator.requireActive(number(settlement, "supplier_id").longValue(), supplierCategory);
|
|
validateVoucherFiles(customerId, request.voucherFileIds());
|
|
|
|
insertPayment(customerId, settlementId, request, operatorId);
|
|
BigDecimal paid = decimal(settlement, "paid_amount").add(request.amount());
|
|
BigDecimal remaining = outstanding.subtract(request.amount());
|
|
String status = remaining.signum() == 0 ? "PAID" : "PARTIALLY_PAID";
|
|
int changed = jdbc.update("UPDATE fin_settlement SET paid_amount=?,outstanding_amount=?,payment_status=?,status=?," +
|
|
"updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND version=?",
|
|
paid, remaining, status, status, operatorId, settlementId, customerId, request.expectedVersion());
|
|
if (changed != 1) throw error("FIN_409", "结算单版本冲突");
|
|
audit(customerId, settlementId, "PAYMENT", request.remark(),
|
|
settlementSnapshot(decimal(settlement, "total_amount"), decimal(settlement, "paid_amount")),
|
|
settlementSnapshot(decimal(settlement, "total_amount"), paid), operatorId);
|
|
complete(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, settlementId);
|
|
return getSettlement(settlementId);
|
|
}
|
|
|
|
@Transactional
|
|
public SettlementDetail actionSettlement(long settlementId, long operatorId, String key,
|
|
SettlementActionRequest request) {
|
|
validateSettlementAction(request);
|
|
Map<String, Object> settlement = one(
|
|
"SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " +
|
|
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
|
|
long customerId = number(settlement, "tenant_id").longValue();
|
|
Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST",
|
|
"/api/platform/finance/v1/settlements/" + settlementId + "/actions", request);
|
|
if (replay != null) return getSettlement(replay);
|
|
if (number(settlement, "version").intValue() != request.expectedVersion()) {
|
|
throw error("FIN_409", "结算单版本冲突");
|
|
}
|
|
String currentStatus = text(settlement, "status");
|
|
String targetStatus;
|
|
if ("VOID".equals(request.action())) {
|
|
if (!"ISSUED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
|
|
throw error("FIN_409", "只有未发生收付款的生效结算单可以作废");
|
|
}
|
|
targetStatus = "VOIDED";
|
|
} else {
|
|
if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
|
|
throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开");
|
|
}
|
|
targetStatus = "ISSUED";
|
|
}
|
|
int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " +
|
|
"WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId,
|
|
request.expectedVersion());
|
|
if (changed != 1) throw error("FIN_409", "结算单版本冲突");
|
|
audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement),
|
|
settlementStateSnapshot(settlement, targetStatus), operatorId);
|
|
complete(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, settlementId);
|
|
return getSettlement(settlementId);
|
|
}
|
|
|
|
private long insertSettlement(long customerId, String no, String direction, String customerName,
|
|
Long supplierId, String supplierName, Long reconciliationId,
|
|
String businessPeriod, String currency, BigDecimal total,
|
|
LocalDate generatedDate, LocalDate dueDate, long operatorId) {
|
|
KeyHolder holder = new GeneratedKeyHolder();
|
|
jdbc.update(connection -> {
|
|
PreparedStatement statement = connection.prepareStatement(
|
|
"INSERT INTO fin_settlement(tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," +
|
|
"supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," +
|
|
"paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version,created_by," +
|
|
"updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?,'UNPAID','ISSUED',0,?,?," +
|
|
"CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))", Statement.RETURN_GENERATED_KEYS);
|
|
statement.setLong(1, customerId); statement.setString(2, no); statement.setString(3, direction);
|
|
statement.setString(4, customerName); statement.setObject(5, supplierId); statement.setString(6, supplierName);
|
|
statement.setObject(7, reconciliationId); statement.setString(8, businessPeriod); statement.setString(9, currency);
|
|
statement.setBigDecimal(10, total); statement.setBigDecimal(11, total); statement.setDate(12, Date.valueOf(generatedDate));
|
|
statement.setDate(13, Date.valueOf(dueDate)); statement.setLong(14, operatorId); statement.setLong(15, operatorId);
|
|
return statement;
|
|
}, holder);
|
|
if (holder.getKey() == null) throw error("FIN_001", "结算单创建后未返回主键");
|
|
return holder.getKey().longValue();
|
|
}
|
|
|
|
private void insertPayment(long customerId, long settlementId, CreateSettlementPaymentRequest request,
|
|
long operatorId) {
|
|
jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," +
|
|
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
|
|
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
|
|
customerId, settlementId, documentNo("PAY"), request.transactionDate(), request.amount(), CNY,
|
|
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()),
|
|
operatorId, FinanceOperatorSnapshot.currentName(operatorId));
|
|
}
|
|
|
|
private void validatePayableRequest(CreatePayableSettlementRequest request) {
|
|
if (request == null || request.customerId() <= 0 || request.supplierId() <= 0 || request.dueDate() == null
|
|
|| request.feeSourceIds() == null || request.feeSourceIds().isEmpty()
|
|
|| request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0)) {
|
|
throw error("FIN_400", "应付结算请求字段不完整");
|
|
}
|
|
if (new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) {
|
|
throw error("FIN_400", "费用来源ID不能重复");
|
|
}
|
|
requireCny(request.currency());
|
|
}
|
|
|
|
private void validatePaymentRequest(CreateSettlementPaymentRequest request) {
|
|
if (request == null || request.transactionDate() == null || request.amount() == null || request.amount().signum() <= 0
|
|
|| request.expectedVersion() == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.method())
|
|
|| request.voucherFileIds() == null || request.voucherFileIds().isEmpty()) {
|
|
throw error("FIN_400", "付款请求字段不完整或金额无效");
|
|
}
|
|
requireCny(request.currency());
|
|
if (!PAYMENT_METHODS.contains(request.method())) throw error("FIN_400", "付款方式无效");
|
|
if (new HashSet<>(request.voucherFileIds()).size() != request.voucherFileIds().size()) {
|
|
throw error("FIN_400", "付款凭证ID不能重复");
|
|
}
|
|
if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "付款备注不能超过500字");
|
|
}
|
|
|
|
private void validateSettlementAction(SettlementActionRequest request) {
|
|
if (request == null || !Set.of("VOID", "REOPEN").contains(request.action())
|
|
|| !StringUtils.hasText(request.reason()) || request.reason().length() > 500
|
|
|| request.expectedVersion() == null || request.expectedVersion() < 0) {
|
|
throw error("FIN_400", "结算单操作、原因或版本无效");
|
|
}
|
|
}
|
|
|
|
private void validateVoucherFiles(long customerId, List<String> fileIds) {
|
|
voucherValidator.validate(customerId, fileIds, "付款凭证");
|
|
}
|
|
|
|
private String payableSupplierCategory(long customerId, long settlementId) {
|
|
Set<String> categories = jdbc.queryForList(
|
|
"SELECT f.fee_item_code,f.fee_item_name FROM fin_settlement_line sl " +
|
|
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
|
|
"WHERE sl.tenant_id=? AND sl.settlement_id=?",
|
|
customerId, settlementId).stream()
|
|
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
|
|
.collect(java.util.stream.Collectors.toSet());
|
|
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
|
|
return categories.iterator().next();
|
|
}
|
|
|
|
private Long reserve(long customerId, long operatorId, String operation, String key,
|
|
String method, String path, Object body) {
|
|
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
|
|
String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
|
|
List<Map<String, Object>> rows = jdbc.queryForList(
|
|
"SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record " +
|
|
"WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
|
|
customerId, operatorId, operation, key);
|
|
if (!rows.isEmpty()) {
|
|
Map<String, Object> row = rows.get(0);
|
|
if (!requestHash.equals(text(row, "request_hash"))) throw error("FIN_409", "Idempotency-Key已用于不同请求");
|
|
if ("SUCCEEDED".equals(text(row, "status")) && row.get("result_resource_id") != null) {
|
|
return number(row, "result_resource_id").longValue();
|
|
}
|
|
if ("FAILED".equals(text(row, "status"))) {
|
|
int reset = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
|
|
customerId, operatorId, operation, key);
|
|
if (reset == 1) return null;
|
|
}
|
|
LocalDateTime updatedAt = localTime(row.get("updated_at"));
|
|
if ("PROCESSING".equals(text(row, "status")) && updatedAt != null
|
|
&& updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) {
|
|
int reset = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
|
|
customerId, operatorId, operation, key, updatedAt);
|
|
if (reset == 1) return null;
|
|
}
|
|
throw error("FIN_409", "相同请求正在处理中或等待恢复");
|
|
}
|
|
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) " +
|
|
"VALUES(?,?,?,?,?,'PROCESSING')", customerId, operatorId, operation, key, requestHash);
|
|
return null;
|
|
}
|
|
|
|
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
|
|
int changed = jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? " +
|
|
"AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'",
|
|
resourceId, customerId, operatorId, operation, key);
|
|
if (changed != 1) throw error("FIN_409", "幂等请求完成状态冲突");
|
|
}
|
|
|
|
private void audit(long customerId, long resourceId, String action, String reason,
|
|
Object before, Object after, long operatorId) {
|
|
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data," +
|
|
"operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
customerId, "FIN_SETTLEMENT", resourceId, action, reason, before == null ? null : json(before),
|
|
json(after), operatorId, FinanceOperatorSnapshot.requiredName(operatorId),
|
|
MDC.get("requestId"), MDC.get("traceId"));
|
|
}
|
|
|
|
private SettlementSummary summary(Map<String, Object> row) {
|
|
return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"),
|
|
text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"),
|
|
nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"),
|
|
nullableNumber(row, "reconciliation_statement_id"), text(row, "business_period"),
|
|
text(row, "currency_code"), decimal(row, "total_amount"), decimal(row, "paid_amount"),
|
|
decimal(row, "outstanding_amount"), date(row, "generated_date"), date(row, "due_date"),
|
|
text(row, "payment_status"), text(row, "status"), number(row, "version").intValue());
|
|
}
|
|
|
|
private SettlementLine line(Map<String, Object> row, SettlementSummary settlement) {
|
|
JsonNode snapshot = parseTree(text(row, "source_snapshot"));
|
|
BigDecimal quantity = jsonDecimalOrNull(snapshot, "chargeableQuantity");
|
|
BigDecimal amount = decimal(row, "amount");
|
|
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
|
|
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
|
|
Long supplierId = jsonLong(snapshot, "supplierId");
|
|
if (supplierId == null) supplierId = jsonLong(snapshot, "partnerId");
|
|
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
|
|
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
|
|
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
|
|
supplierId, firstText(jsonText(snapshot, "supplierName"),
|
|
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
|
|
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
|
|
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
|
|
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
|
|
jsonBoolean(snapshot, "settlementIncluded"), text(row, "status"), true);
|
|
}
|
|
|
|
private SettlementTransaction transaction(Map<String, Object> row) {
|
|
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
|
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
|
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
|
text(row, "created_by_name"),
|
|
time(row, "created_at"), text(row, "remark"));
|
|
}
|
|
|
|
private SettlementRevision revision(int revisionNo, Map<String, Object> row) {
|
|
JsonNode before = parseTree(text(row, "before_data"));
|
|
JsonNode after = parseTree(text(row, "after_data"));
|
|
return new SettlementRevision(revisionNo, text(row, "action"), jsonDecimal(before, "totalAmount"),
|
|
jsonDecimal(after, "totalAmount"), text(row, "reason"), number(row, "operator_id").longValue(),
|
|
text(row, "operator_name_snapshot"),
|
|
time(row, "created_at"));
|
|
}
|
|
|
|
private Map<String, Object> one(String sql, String message, Object... args) {
|
|
List<Map<String, Object>> rows = jdbc.queryForList(sql, args);
|
|
if (rows.isEmpty()) throw error("FIN_404", message);
|
|
return rows.get(0);
|
|
}
|
|
|
|
private static void requireCny(String currency) {
|
|
if (!CNY.equals(currency)) throw error("FIN_400", "一期仅支持CNY");
|
|
}
|
|
|
|
private static Map<String, Object> settlementSnapshot(BigDecimal totalAmount, BigDecimal paidAmount) {
|
|
return Map.of("totalAmount", totalAmount, "paidAmount", paidAmount,
|
|
"outstandingAmount", totalAmount.subtract(paidAmount));
|
|
}
|
|
|
|
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement) {
|
|
return settlementStateSnapshot(settlement, text(settlement, "status"));
|
|
}
|
|
|
|
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement, String status) {
|
|
return Map.of("totalAmount", decimal(settlement, "total_amount"),
|
|
"paidAmount", decimal(settlement, "paid_amount"),
|
|
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
|
}
|
|
|
|
private String documentNo(String prefix) {
|
|
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
|
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
|
}
|
|
|
|
private String hash(Object value) {
|
|
try {
|
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
|
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
|
} catch (Exception failure) {
|
|
throw error("FIN_001", "幂等请求哈希失败");
|
|
}
|
|
}
|
|
|
|
private String json(Object value) {
|
|
try { return objectMapper.writeValueAsString(value); }
|
|
catch (Exception failure) { throw error("FIN_001", "财务数据序列化失败"); }
|
|
}
|
|
|
|
private JsonNode parseTree(String value) {
|
|
if (!StringUtils.hasText(value)) return objectMapper.createObjectNode();
|
|
try { return objectMapper.readTree(value); }
|
|
catch (Exception failure) { throw error("FIN_001", "财务快照数据损坏"); }
|
|
}
|
|
|
|
private List<String> stringList(String value) {
|
|
if (!StringUtils.hasText(value)) return List.of();
|
|
try { return objectMapper.readValue(value, new TypeReference<List<String>>() {}); }
|
|
catch (Exception failure) { throw error("FIN_001", "财务凭证数据损坏"); }
|
|
}
|
|
|
|
private static String text(Map<String, Object> row, String key) {
|
|
Object value = row.get(key);
|
|
return value == null ? null : value.toString();
|
|
}
|
|
private static Number number(Map<String, Object> row, String key) {
|
|
Object value = row.get(key);
|
|
if (value instanceof Number number) return number;
|
|
if (value == null) throw error("FIN_001", "财务字段缺失:" + key);
|
|
return new BigDecimal(value.toString());
|
|
}
|
|
private static Long nullableNumber(Map<String, Object> row, String key) {
|
|
Object value = row.get(key);
|
|
return value == null ? null : (value instanceof Number number ? number.longValue() : Long.valueOf(value.toString()));
|
|
}
|
|
private static BigDecimal decimal(Map<String, Object> row, String key) {
|
|
Object value = row.get(key);
|
|
if (value == null) return BigDecimal.ZERO;
|
|
return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString());
|
|
}
|
|
private static LocalDate date(Map<String, Object> row, String key) {
|
|
Object value = row.get(key);
|
|
if (value instanceof Date date) return date.toLocalDate();
|
|
if (value instanceof LocalDate date) return date;
|
|
return LocalDate.parse(value.toString());
|
|
}
|
|
private static LocalDateTime time(Map<String, Object> row, String key) {
|
|
Object value = row.get(key);
|
|
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
|
|
if (value instanceof LocalDateTime time) return time;
|
|
return LocalDateTime.parse(value.toString());
|
|
}
|
|
private static LocalDateTime localTime(Object value) {
|
|
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
|
|
if (value instanceof LocalDateTime time) return time;
|
|
return value == null ? null : LocalDateTime.parse(value.toString());
|
|
}
|
|
private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
|
private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); }
|
|
private static String jsonText(JsonNode node, String key) {
|
|
JsonNode value = node.path(key);
|
|
return value.isMissingNode() || value.isNull() ? null : value.asText();
|
|
}
|
|
private static Long jsonLong(JsonNode node, String key) {
|
|
JsonNode value = node.path(key);
|
|
return value.isIntegralNumber() ? value.longValue() : null;
|
|
}
|
|
private static LocalDate jsonDate(JsonNode node, String key) {
|
|
String value = jsonText(node, key);
|
|
return StringUtils.hasText(value) ? LocalDate.parse(value) : null;
|
|
}
|
|
private static BigDecimal jsonDecimal(JsonNode node, String key) {
|
|
JsonNode value = node.path(key);
|
|
if (value.isNumber()) return value.decimalValue();
|
|
if (value.isTextual() && StringUtils.hasText(value.asText())) return new BigDecimal(value.asText());
|
|
return BigDecimal.ZERO;
|
|
}
|
|
private static BigDecimal jsonDecimalOrNull(JsonNode node, String key) {
|
|
JsonNode value = node.path(key);
|
|
if (value.isNumber()) return value.decimalValue();
|
|
if (value.isTextual() && StringUtils.hasText(value.asText())) return new BigDecimal(value.asText());
|
|
return null;
|
|
}
|
|
private static boolean jsonBoolean(JsonNode node, String key) { return node.path(key).asBoolean(false); }
|
|
private static String firstText(String first, String fallback) { return StringUtils.hasText(first) ? first : fallback; }
|
|
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
|
|
}
|