This commit is contained in:
2026-08-31 18:55:03 +08:00
parent 9b09663b2e
commit 01385c4bc7
17 changed files with 207 additions and 71 deletions
@@ -16,8 +16,10 @@ import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
@@ -27,11 +29,36 @@ import org.springframework.jdbc.BadSqlGrammarException;
class CustomerAccountSummaryBehaviorTest {
@Test
void accountReadUsesFormalArSettlementsAndKeepsReceiptHistorySeparate() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(anyString(), org.mockito.ArgumentMatchers.eq(BigDecimal.class),
org.mockito.ArgumentMatchers.eq(9L))).thenReturn(new BigDecimal("100.00"));
when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L)))
.thenReturn(List.of());
var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L);
assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER");
var amountSql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, org.mockito.Mockito.times(3)).queryForObject(amountSql.capture(),
org.mockito.ArgumentMatchers.eq(BigDecimal.class), org.mockito.ArgumentMatchers.eq(9L));
assertThat(amountSql.getAllValues()).allSatisfy(sql -> assertThat(sql)
.contains("FROM fin_settlement", "direction='RECEIVABLE'", "status<>'VOIDED'"));
var entrySql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, org.mockito.Mockito.times(2)).query(entrySql.capture(), any(RowMapper.class),
org.mockito.ArgumentMatchers.eq(9L));
assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql)
.contains("settlement_no", "FROM fin_settlement", "direction='RECEIVABLE'"));
assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql)
.contains("FROM fin_receipt", "status<>'VOIDED'"));
}
@Test
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
@@ -52,14 +79,15 @@ class CustomerAccountSummaryBehaviorTest {
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(9L));
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L));
assertThat(sql.getValue())
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.status='CONFIRMED'", "s.paid_amount")
.contains("os.direction='PAYABLE' AND os.status<>'VOIDED'")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin='FIRSTLEG_DIRECT_PAID'", "ps.paid_amount")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
.contains("b.status<>'VOIDED'")
.contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
}
@@ -68,7 +96,7 @@ class CustomerAccountSummaryBehaviorTest {
void emptyCustomerReturnsZeroAmounts() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
@@ -83,23 +111,13 @@ class CustomerAccountSummaryBehaviorTest {
}
@Test
void missingSettlementProjectionReturnsZeroCnyBaseline() {
void missingSettlementProjectionFailsClosedInsteadOfReturningFakeZeroAmounts() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any()))
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
assertThat(result.customerId()).isEqualTo(88L);
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.totalReceivable()).isZero();
assertThat(result.receivedAmount()).isZero();
assertThat(result.outstandingReceivable()).isZero();
assertThat(result.pendingBilling()).isZero();
assertThat(result.totalCost()).isZero();
assertThat(result.paidCost()).isZero();
assertThat(result.outstandingPayable()).isZero();
assertThat(result.lastChangedAt()).isNull();
assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L))
.isInstanceOf(BadSqlGrammarException.class);
}
@Test
@@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -42,6 +43,29 @@ class FinanceCustomerAccountServiceTest {
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
}
@Test
void operatingFundQueriesUseFormalArUnbilledApDedupAndSupplierOnlyPaidAmounts() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
IamCustomerClient iam = mock(IamCustomerClient.class);
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
when(iam.page("internal-token", 1, 20, null, null, 9L)).thenReturn(ApiResponse.success(
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
1, 20, 1, 1)));
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
String all = String.join("\n", sql.getAllValues());
assertThat(all)
.contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'")
.contains("cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'")
.contains("direction='PAYABLE' AND status<>'VOIDED'")
.contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line")
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'")
.contains("review_status='APPROVED'", "currency_code='CNY'", "amount>0");
}
@Test
void rejectsUnsupportedFinanceAccountStatus() {
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
@@ -49,6 +49,11 @@ class FinanceExportContractTest {
assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:")
.contains("customerId?, status?, settlementNo?")
.contains("所有 DETAIL 仅 settlementId")
.contains("仅三类运营资金:待收客户款、待付供应商款、已线下支付供应商款")
.contains("operatingCards: [outstandingReceivable, outstandingPayable, paidCost]")
.contains("辅助字段只允许用于列表、明细或历史说明,不得生成第四类运营资金卡片")
.contains("AP outstanding加未制单有效ON_ACCOUNT来源并去重")
.contains("已线下支付供应商款;不存在客户线下支付含义")
.doesNotContain("DETAIL: settlementId, direction");
for (String type : TYPES) {
assertThat(openApi).contains("- " + type);
@@ -147,6 +147,22 @@ class FinanceFeeSourceEventServiceTest {
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void adjustmentRejectsSourceFrozenByActiveBillOrAp() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-frozen", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
givenNewEvent();
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"),
eq(Integer.class), eq(31L))).thenReturn(1);
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016");
verify(jdbc, never()).update(contains("status='SUPERSEDED'"), anyLong());
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void voidMarksExistingVersionWithoutDeletingHistory() {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
@@ -160,6 +176,24 @@ class FinanceFeeSourceEventServiceTest {
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
}
@Test
void voidRejectsSourceFrozenByActiveBillOrAp() {
FinanceFeeSourceEvent event = inboundEvent("voided-frozen", "FIRSTLEG_FEE_VOIDED", 2, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-frozen")))
.thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"),
eq(Integer.class), eq(88L))).thenReturn(1);
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016");
verify(jdbc, never()).update(contains("SET status='VOIDED'"), eq(88L));
}
@Test
void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception {
FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
@@ -106,6 +106,9 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.requestId()).isNotBlank();
assertThat(result.traceId()).isNotBlank();
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
assertThat(jdbc.accountListSql)
.contains("ar.direction='RECEIVABLE'", "cost_origin='FIRSTLEG_DIRECT_PAID'", "FROM fin_settlement_line sl")
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'");
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
}
@@ -118,7 +121,11 @@ class FinancePhase1OperationBehaviorTest {
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
assertThat(result.data()).isEmpty();
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L);
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L);
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'"));
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
}
@Test
@@ -349,6 +356,7 @@ class FinancePhase1OperationBehaviorTest {
private static final class AccountJdbc extends JdbcTemplate {
private final List<Long> ledgerCustomerIds = new ArrayList<>();
private final List<String> ledgerSql = new ArrayList<>();
private String accountListSql = "";
@Override
@@ -369,8 +377,9 @@ class FinancePhase1OperationBehaviorTest {
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|| sql.startsWith("SELECT id,cost_no")) {
|| sql.startsWith("SELECT id,cost_no") || sql.startsWith("SELECT p.id")) {
ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue());
ledgerSql.add(sql);
return List.of();
}
return List.of();
@@ -56,6 +56,8 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
assertThat(sql).contains("仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款");
assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
}
@@ -72,6 +74,17 @@ class FinanceSqlCommentContractTest {
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
}
@Test
void operatingFundAggregatesHaveDedicatedBaselineIndexes() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql)
.contains("KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id)")
.contains("KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status)")
.contains("KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
.contains("KEY idx_fin_settlement_line_source (fee_source_id)")
.contains("KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id)");
}
@Test
void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);