2026-08-11 18:43:50 +08:00
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 ;
2026-08-13 11:57:33 +08:00
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest ;
2026-08-11 18:43:50 +08:00
import com.fasterxml.jackson.core.type.TypeReference ;
import com.fasterxml.jackson.databind.JsonNode ;
import com.fasterxml.jackson.databind.ObjectMapper ;
2026-10-21 10:41:38 +08:00
import com.fasterxml.jackson.databind.SerializationFeature ;
2026-08-11 18:43:50 +08:00
import org.slf4j.MDC ;
import org.springframework.jdbc.core.JdbcTemplate ;
import org.springframework.jdbc.support.GeneratedKeyHolder ;
import org.springframework.jdbc.support.KeyHolder ;
2026-08-19 09:14:30 +08:00
import org.springframework.beans.factory.annotation.Autowired ;
2026-08-11 18:43:50 +08:00
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" );
2026-10-21 11:43:55 +08:00
private static final String SETTLEMENT_SUMMARY_SELECT = com . baiyue . finance . mapper . FinanceSettlementDetailMapper . SUMMARY_SELECT ;
2026-08-11 18:43:50 +08:00
private final JdbcTemplate jdbc ;
2026-10-21 11:43:55 +08:00
private final com . baiyue . finance . repository . FinanceSettlementDetailRepository details ;
2026-08-11 18:43:50 +08:00
private final ObjectMapper objectMapper ;
2026-08-14 09:14:52 +08:00
private final FinanceVoucherValidator voucherValidator ;
2026-08-15 11:05:58 +08:00
private final FinanceSupplierValidator supplierValidator ;
2026-08-19 09:14:30 +08:00
private final FinanceDocumentNumberGenerator documentNumbers ;
2026-08-11 18:43:50 +08:00
2026-10-21 07:53:31 +08:00
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
2026-08-19 09:14:30 +08:00
@Autowired
public FinanceSettlementService ( JdbcTemplate jdbc , ObjectMapper objectMapper , FinanceVoucherValidator voucherValidator ,
FinanceSupplierValidator supplierValidator ,
2026-10-21 11:43:55 +08:00
FinanceDocumentNumberGenerator documentNumbers ,
com . baiyue . finance . repository . FinanceSettlementDetailRepository details ) {
2026-08-11 18:43:50 +08:00
this . jdbc = jdbc ;
2026-10-21 11:43:55 +08:00
this . details = details ;
2026-08-11 18:43:50 +08:00
this . objectMapper = objectMapper ;
2026-08-14 09:14:52 +08:00
this . voucherValidator = voucherValidator ;
2026-08-15 11:05:58 +08:00
this . supplierValidator = supplierValidator ;
2026-08-19 09:14:30 +08:00
this . documentNumbers = documentNumbers ;
2026-08-11 18:43:50 +08:00
}
2026-10-21 07:53:31 +08:00
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
2026-08-11 18:43:50 +08:00
@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 ();
2026-08-19 09:14:30 +08:00
long settlementId = insertSettlement ( customerId , documentNumbers . next ( "AR" ), "RECEIVABLE" ,
2026-08-11 18:43:50 +08:00
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 " +
2026-08-12 15:21:23 +08:00
"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=?" ,
2026-08-11 18:43:50 +08:00
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 (
2026-08-20 18:02:20 +08:00
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,settlement_mode,source_snapshot," +
2026-08-19 09:14:30 +08:00
"review_status," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierCategory')) supplier_category " +
2026-08-11 18:43:50 +08:00
"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" , "应付来源必须属于同一供应商" );
2026-08-20 18:02:20 +08:00
if ( ! "ON_ACCOUNT" . equals ( text ( source , "settlement_mode" ))) {
throw error ( "FIN_409" , "只有ON_ACCOUNT账期应付费用可以生成应付结算单" );
}
2026-08-11 18:43:50 +08:00
if ( ! "PAYABLE" . equals ( text ( source , "direction" )) || ! "AVAILABLE" . equals ( text ( source , "status" ))
2026-08-19 09:14:30 +08:00
|| ! "APPROVED" . equals ( text ( source , "review_status" )) || decimal ( source , "amount" ). signum () <= 0 ) {
throw error ( "FIN_409" , "费用来源未审核、金额无效或状态不可用" );
2026-08-11 18:43:50 +08:00
}
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" );
2026-08-19 09:42:07 +08:00
String supplierCategory = supplierCategoryConstraint ( sources );
String supplierName = supplierValidator . requireActive ( request . supplierId (), supplierCategory ). name ();
2026-08-11 18:43:50 +08:00
String customerName = sources . stream (). map ( row -> jsonText ( parseTree ( text ( row , "source_snapshot" )), "customerName" ))
. filter ( StringUtils :: hasText ). findFirst (). orElse ( "客户" + customerId );
LocalDate generatedDate = LocalDate . now ();
2026-08-19 09:14:30 +08:00
long settlementId = insertSettlement ( customerId , documentNumbers . next ( "AP" ), "PAYABLE" , customerName ,
2026-08-11 18:43:50 +08:00
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 );
}
2026-10-21 11:43:55 +08:00
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
2026-08-11 18:43:50 +08:00
public SettlementDetail getSettlement ( long settlementId ) {
if ( settlementId <= 0 ) throw error ( "FIN_404" , "结算单不存在" );
2026-10-21 11:43:55 +08:00
List < Map < String , Object >> records = details . summary ( settlementId );
if ( records . isEmpty ()) throw error ( "FIN_404" , "结算单不存在" );
SettlementSummary summary = summary ( records . get ( 0 ));
List < SettlementLine > lines = details . lines ( summary . customerId (), settlementId )
2026-08-11 18:43:50 +08:00
. stream (). map ( source -> line ( source , summary )). toList ();
2026-10-21 11:43:55 +08:00
List < SettlementTransaction > transactions = new ArrayList <> ( details . payments ( summary . customerId (), settlementId )
. stream (). map ( this :: transaction ). toList ());
2026-08-12 09:25:53 +08:00
if ( "RECEIVABLE" . equals ( summary . direction ()) && summary . reconciliationStatementId () != null ) {
2026-10-21 11:43:55 +08:00
transactions . addAll ( details . receipts ( summary . customerId (), summary . reconciliationStatementId ())
. stream (). map ( this :: transaction ). toList ());
2026-08-12 09:25:53 +08:00
}
2026-10-21 11:43:55 +08:00
List < Map < String , Object >> audits = details . audits ( summary . customerId (), settlementId );
2026-08-11 18:43:50 +08:00
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 ));
}
2026-08-13 09:49:55 +08:00
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" , "客户范围无效" );
}
2026-08-17 19:51:54 +08:00
String sql = SETTLEMENT_SUMMARY_SELECT + "WHERE s.direction=?" ;
2026-08-13 09:49:55 +08:00
List < Object > args = new ArrayList <> ();
args . add ( direction );
if ( customerId != null ) {
2026-08-17 19:51:54 +08:00
sql += " AND s.tenant_id=?" ;
2026-08-13 09:49:55 +08:00
args . add ( customerId );
}
2026-08-17 19:51:54 +08:00
sql += " ORDER BY s.generated_date DESC,s.id DESC" ;
2026-08-13 09:49:55 +08:00
return jdbc . queryForList ( sql , args . toArray ()). stream (). map ( this :: summary ). toList ();
}
2026-08-11 18:43:50 +08:00
@Transactional
public SettlementDetail createSettlementPayment ( long settlementId , long operatorId , String key ,
CreateSettlementPaymentRequest request ) {
validatePaymentRequest ( request );
Map < String , Object > settlement = one (
2026-08-15 11:05:58 +08:00
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version,supplier_id " +
2026-08-11 18:43:50 +08:00
"FROM fin_settlement WHERE id=? FOR UPDATE" , "结算单不存在" , settlementId );
long customerId = number ( settlement , "tenant_id" ). longValue ();
2026-08-12 18:23:57 +08:00
Long replay = reserve ( customerId , operatorId , "SETTLEMENT_PAYMENT_CREATE" , key , "POST" ,
"/api/platform/finance/v1/settlements/" + settlementId + "/transactions" , request );
if ( replay != null ) return getSettlement ( replay );
2026-08-11 18:43:50 +08:00
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" , "付款金额不得超过未付余额" );
2026-08-15 11:05:58 +08:00
String supplierCategory = payableSupplierCategory ( customerId , settlementId );
supplierValidator . requireActive ( number ( settlement , "supplier_id" ). longValue (), supplierCategory );
2026-08-11 18:43:50 +08:00
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 );
}
2026-08-13 11:57:33 +08:00
@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 );
}
2026-08-11 18:43:50 +08:00
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," +
2026-08-17 15:41:42 +08:00
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))" ,
2026-08-19 09:14:30 +08:00
customerId , settlementId , transactionNo (), request . transactionDate (), request . amount (), CNY ,
2026-08-17 15:41:42 +08:00
request . method (), blank ( request . referenceNo ()), json ( request . voucherFileIds ()), blank ( request . remark ()),
operatorId , FinanceOperatorSnapshot . currentName ( operatorId ));
2026-08-11 18:43:50 +08:00
}
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字" );
}
2026-08-13 11:57:33 +08:00
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" , "结算单操作、原因或版本无效" );
}
}
2026-08-11 18:43:50 +08:00
private void validateVoucherFiles ( long customerId , List < String > fileIds ) {
2026-08-14 09:14:52 +08:00
voucherValidator . validate ( customerId , fileIds , "付款凭证" );
2026-08-11 18:43:50 +08:00
}
2026-08-15 11:05:58 +08:00
private String payableSupplierCategory ( long customerId , long settlementId ) {
2026-08-19 09:42:07 +08:00
return supplierCategoryConstraint ( jdbc . queryForList (
2026-08-19 09:14:30 +08:00
"SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " +
2026-08-15 11:05:58 +08:00
"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=?" ,
2026-08-19 09:42:07 +08:00
customerId , settlementId ));
}
private String supplierCategoryConstraint ( List < Map < String , Object >> sources ) {
Set < String > categories = sources . stream (). map ( row -> text ( row , "supplier_category" ))
. filter ( StringUtils :: hasText ). collect ( java . util . stream . Collectors . toSet ());
if ( categories . size () > 1 ) throw error ( "FIN_409" , "应付费用来源供应商类别约束不一致" );
return categories . isEmpty () ? null : categories . iterator (). next ();
2026-08-15 11:05:58 +08:00
}
2026-08-11 18:43:50 +08:00
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 (
2026-08-12 09:25:53 +08:00
"SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record " +
2026-08-11 18:43:50 +08:00
"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 ();
}
2026-08-12 09:25:53 +08:00
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 ;
}
2026-08-11 18:43:50 +08:00
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 ) {
2026-08-12 09:25:53 +08:00
int changed = jdbc . update ( "UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? " +
2026-08-11 18:43:50 +08:00
"AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'" ,
resourceId , customerId , operatorId , operation , key );
2026-08-12 09:25:53 +08:00
if ( changed != 1 ) throw error ( "FIN_409" , "幂等请求完成状态冲突" );
2026-08-11 18:43:50 +08:00
}
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 ),
2026-08-17 15:41:42 +08:00
json ( after ), operatorId , FinanceOperatorSnapshot . requiredName ( operatorId ),
MDC . get ( "requestId" ), MDC . get ( "traceId" ));
2026-08-11 18:43:50 +08:00
}
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" ),
2026-08-17 19:51:54 +08:00
nullableNumber ( row , "reconciliation_statement_id" ), text ( row , "reconciliation_statement_no" ),
text ( row , "business_period" ), text ( row , "fee_item_summary" ),
2026-08-11 18:43:50 +08:00
text ( row , "currency_code" ), decimal ( row , "total_amount" ), decimal ( row , "paid_amount" ),
decimal ( row , "outstanding_amount" ), date ( row , "generated_date" ), date ( row , "due_date" ),
2026-08-17 19:51:54 +08:00
text ( row , "payment_status" ), text ( row , "status" ), number ( row , "created_by" ). longValue (),
text ( row , "created_by_name" ), time ( row , "created_at" ), time ( row , "updated_at" ),
nullableTime ( row . get ( "last_payment_at" )), number ( row , "version" ). intValue ());
2026-08-11 18:43:50 +08:00
}
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 );
2026-08-15 11:05:58 +08:00
Long supplierId = jsonLong ( snapshot , "supplierId" );
2026-08-20 16:13:51 +08:00
if ( "PAYABLE" . equals ( text ( row , "direction" )) && supplierId == null ) {
throw error ( "FIN_409" , "应付结算缺少供应商快照" );
}
2026-08-11 18:43:50 +08:00
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" ),
2026-08-19 09:14:30 +08:00
supplierId , jsonText ( snapshot , "supplierName" ),
2026-08-17 19:51:54 +08:00
nullableNumber ( row , "order_id" ), jsonText ( snapshot , "orderNo" ),
2026-08-11 18:43:50 +08:00
nullableNumber ( row , "inbound_id" ), jsonText ( snapshot , "inboundCode" ), nullableNumber ( row , "container_id" ),
jsonText ( snapshot , "containerNo" ), jsonDate ( snapshot , "businessDate" ), text ( row , "fee_item_name" ),
2026-08-19 09:14:30 +08:00
quantity , unitPrice , amount , text ( row , "currency_code" ), text ( row , "review_status" ),
text ( row , "status" ), true );
2026-08-11 18:43:50 +08:00
}
2026-10-21 11:43:55 +08:00
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
2026-08-11 18:43:50 +08:00
private SettlementTransaction transaction ( Map < String , Object > row ) {
2026-10-21 11:43:55 +08:00
return new SettlementTransaction ( number ( row , "id" ). longValue (), text ( row , "transaction_no" ), text ( row , "transaction_type" ),
2026-08-11 18:43:50 +08:00
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 (),
2026-08-17 15:41:42 +08:00
text ( row , "created_by_name" ),
2026-08-11 18:43:50 +08:00
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 (),
2026-08-17 15:41:42 +08:00
text ( row , "operator_name_snapshot" ),
2026-08-11 18:43:50 +08:00
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 ));
}
2026-08-13 11:57:33 +08:00
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 );
}
2026-10-20 23:49:55 +08:00
/** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */
2026-08-19 09:14:30 +08:00
private String transactionNo () {
2026-10-20 23:49:55 +08:00
return documentNumbers . next ( "PAY" );
2026-08-11 18:43:50 +08:00
}
2026-10-21 10:41:38 +08:00
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
2026-08-11 18:43:50 +08:00
private String hash ( Object value ) {
try {
2026-10-21 10:41:38 +08:00
String canonical = objectMapper . writer ()
. with ( SerializationFeature . ORDER_MAP_ENTRIES_BY_KEYS ). writeValueAsString ( value );
2026-08-11 18:43:50 +08:00
return HexFormat . of (). formatHex ( MessageDigest . getInstance ( "SHA-256" )
2026-10-21 10:41:38 +08:00
. digest ( canonical . getBytes ( StandardCharsets . UTF_8 )));
2026-08-11 18:43:50 +08:00
} 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 ());
}
2026-08-12 09:25:53 +08:00
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 ());
}
2026-08-17 19:51:54 +08:00
private static LocalDateTime nullableTime ( Object value ) {
return value == null ? null : localTime ( value );
}
2026-08-11 18:43:50 +08:00
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 );
2026-08-20 16:13:51 +08:00
if ( value . isIntegralNumber () && value . canConvertToLong ()) {
long parsed = value . longValue ();
return parsed > 0 ? parsed : null ;
}
if ( ! value . isTextual ()) return null ;
try {
long parsed = Long . parseLong ( value . textValue ());
return parsed > 0 ? parsed : null ;
} catch ( NumberFormatException ignored ) {
return null ;
}
2026-08-11 18:43:50 +08:00
}
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 ); }
}