Files
baiyue-firstleg/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java
T

724 lines
48 KiB
Java

package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.controller.FinanceSettlementController;
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.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
@ExtendWith(MockitoExtension.class)
class FinanceSettlementBehaviorTest {
@Mock FinanceSettlementService controllerService;
@Test
void controllerReturnsFixedEnvelopeAndEnforcesDirectionPermission() {
FinanceSettlementController controller = new FinanceSettlementController(controllerService);
SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0);
when(controllerService.getSettlement(88L)).thenReturn(payable);
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:list"));
var response = controller.getSettlement(apViewer, 88L);
assertThat(response.code()).isEqualTo("200");
assertThat(response.data()).isSameAs(payable);
assertThat(response.requestId()).isNotBlank();
assertThatThrownBy(() -> controller.getSettlement(
user(Set.of("platform:finance:settlement-ar:list")), 88L)).isInstanceOf(AccessDeniedException.class);
}
@Test
void controllerListsFormalSettlementsByDirection() {
FinanceSettlementController controller = new FinanceSettlementController(controllerService);
SettlementSummary receivable = detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary();
when(controllerService.listSettlements("RECEIVABLE", 10L)).thenReturn(List.of(receivable));
var response = controller.receivableSettlements(10L);
assertThat(response.code()).isEqualTo("200");
assertThat(response.data()).containsExactly(receivable);
verify(controllerService).listSettlements("RECEIVABLE", 10L);
}
@Test
void formalSettlementListUsesFinSettlementAndOptionalCustomerFilter() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
Map<String, Object> row = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0);
when(jdbc.queryForList(anyString(), eq("PAYABLE"))).thenReturn(List.of(row));
when(jdbc.queryForList(anyString(), eq("PAYABLE"), eq(10L))).thenReturn(List.of(row));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
var all = service.listSettlements("PAYABLE", null);
var scoped = service.listSettlements("PAYABLE", 10L);
assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L);
assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L);
ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
verify(jdbc).queryForList(scopedSql.capture(), eq("PAYABLE"), eq(10L));
assertThat(allSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?")
.doesNotContain("FROM fin_cost", "AND s.tenant_id=?");
assertThat(scopedSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?", "AND s.tenant_id=?")
.doesNotContain("FROM fin_cost");
}
@Test
void arCreationRejectsNonCnyBeforeDatabaseAccess() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createReceivableSettlement(7L, "ar-key",
new CreateReceivableSettlementRequest(9L, "USD")), "FIN_400");
verify(jdbc, never()).queryForList(anyString(), (Object[]) any());
}
@Test
void payableCreationRejectsDuplicateSourcesBeforeIdempotencyReservation() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(3L, 3L), LocalDate.now(), "CNY")), "FIN_400");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_idempotency_record"), (Object[]) any());
}
@Test
void paymentFailsClosedWhenFileServiceIsUnavailable() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0)));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenThrow(new IllegalStateException("down"));
FinanceSettlementService service = service(jdbc, fileClient);
assertThatThrownBy(() -> service.createSettlementPayment(88L, 7L, "pay-key",
payment(new BigDecimal("40.00"), 0)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any());
}
@Test
void paymentRejectsInvalidCurrencyMissingVoucherStaleVersionAndOverage() {
JdbcTemplate noDatabase = org.mockito.Mockito.mock(JdbcTemplate.class);
FinanceSettlementService validationService = service(noDatabase, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> validationService.createSettlementPayment(88L, 7L, "usd-key",
new CreateSettlementPaymentRequest(LocalDate.now(), BigDecimal.ONE, "USD", "BANK_TRANSFER", "BANK-1", List.of("91"), 0, null)), "FIN_400");
assertCode(() -> validationService.createSettlementPayment(88L, 7L, "voucher-key",
new CreateSettlementPaymentRequest(LocalDate.now(), BigDecimal.ONE, "CNY", "BANK_TRANSFER", "BANK-1", List.of(), 0, null)), "FIN_400");
verify(noDatabase, never()).queryForList(anyString(), (Object[]) any());
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 2)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createSettlementPayment(88L, 7L, "stale-key", payment(new BigDecimal("40.00"), 1)), "FIN_409");
assertCode(() -> service.createSettlementPayment(88L, 7L, "over-key", payment(new BigDecimal("100.01"), 2)), "FIN_409");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
}
@Test
void unpaidSettlementCanBeVoidedAndReopenedWithAuditAndVersionCheck() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
Map<String, Object> issued = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 2);
Map<String, Object> voided = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
new BigDecimal("100.00"), 3);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(issued));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), eq("void-key"))).thenReturn(List.of());
stubSettlementDetail(jdbc, voided);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.actionSettlement(88L, 7L, "void-key",
new SettlementActionRequest("VOID", "业务取消", 2));
assertThat(result.summary().status()).isEqualTo("VOIDED");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("VOIDED"), eq(7L),
eq(88L), eq(10L), eq(2));
verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"),
eq(88L), eq("VOID"), eq("业务取消"), any(), any(), eq(7L), any(), any(), any());
}
@Test
void settlementActionRejectsPaidInvalidStateAndStaleVersion() {
JdbcTemplate paidJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(paidJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 2)));
when(paidJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), anyString())).thenReturn(List.of());
FinanceSettlementService paidService = service(paidJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> paidService.actionSettlement(88L, 7L, "paid-key",
new SettlementActionRequest("VOID", "不能作废", 2)), "FIN_409");
JdbcTemplate staleJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(staleJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
new BigDecimal("100.00"), 3)));
when(staleJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), eq("stale-action"))).thenReturn(List.of());
FinanceSettlementService staleService = service(staleJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> staleService.actionSettlement(88L, 7L, "stale-action",
new SettlementActionRequest("REOPEN", "恢复结算", 2)), "FIN_409");
verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
@Test
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName())
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class);
Map<String, Object> before = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0);
Map<String, Object> after = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 1);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf(LocalDateTime.of(2026, 8, 11, 10, 0))),
Map.entry("remark", "首笔付款"))));
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(10L), eq(88L))).thenReturn(List.of());
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSettlementService service = service(jdbc, fileClient);
SettlementDetail result = service.createSettlementPayment(88L, 7L, "pay-key",
payment(new BigDecimal("40.00"), 0));
assertThat(result.summary().status()).isEqualTo("PARTIALLY_PAID");
assertThat(result.summary().paidAmount()).isEqualByComparingTo("40.00");
assertThat(result.summary().outstandingAmount()).isEqualByComparingTo("60.00");
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("PAYMENT");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void issuedReconciliationCreatesUniqueReceivableSettlement() {
Map<String, Object> settlement = settlementRow("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("300.00"), 0);
settlement.put("reconciliation_statement_id", 501L);
JdbcTemplate jdbc = creationJdbc(settlement);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"),
Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"),
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(0);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.createReceivableSettlement(7L, "ar-key",
new CreateReceivableSettlementRequest(501L, "CNY"));
assertThat(result.summary().direction()).isEqualTo("RECEIVABLE");
assertThat(result.summary().totalAmount()).isEqualByComparingTo("300.00");
assertThat(result.summary().reconciliationStatementId()).isEqualTo(501L);
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(88L), eq(10L), eq(501L));
}
@Test
void receivableSettlementLineDoesNotRequireSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "FBA_PRICING"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "RECEIVABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "头程运费"), Map.entry("amount", new BigDecimal("100.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "GENERATED")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary());
assertThat(line.direction()).isEqualTo("RECEIVABLE");
assertThat(line.supplierId()).isNull();
assertThat(line.customerName()).isEqualTo("客户10");
}
@Test
void payableSettlementLineStillRequiresSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("direction", "PAYABLE"),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("amount", new BigDecimal("100.00"))));
assertCode(() -> ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary()), "FIN_409");
}
@Test
void payableSettlementLineAcceptsStringEncodedSupplierIdSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "INBOUND_FEE"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "PAYABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"supplierId\":\"2090349552523644929\",\"supplierName\":\"供应商A\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "装车费"), Map.entry("amount", new BigDecimal("80.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "AVAILABLE")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("80.00"), 0).summary());
assertThat(line.supplierId()).isEqualTo(2090349552523644929L);
assertThat(line.supplierName()).isEqualTo("供应商A");
}
@Test
void receivableCreationRejectsAlreadySettledStatement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"),
Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"),
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-duplicate"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createReceivableSettlement(7L, "ar-duplicate",
new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void eligibleSameSupplierSourcesCreatePayableSettlement() {
JdbcTemplate jdbc = creationJdbc(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0);
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator);
SettlementDetail result = service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY"));
assertThat(result.summary().direction()).isEqualTo("PAYABLE");
assertThat(result.summary().supplierId()).isEqualTo(20L);
assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00");
verify(supplierValidator).requireActive(20L, null);
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L),
eq(new BigDecimal("100.00")), eq("CNY"));
}
@Test
void payableCreationHidesCrossCustomerSource() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(11L, 20L)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_404");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void directPaidSourceCannotEnterPayableSettlement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_AP_CREATE"), eq("ap-direct"))).thenReturn(List.of());
Map<String, Object> source = payableSource(10L, 20L);
source.put("settlement_mode", "DIRECT_PAID");
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createPayableSettlement(7L, "ap-direct",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L),
LocalDate.of(2026, 9, 30), "CNY")), "FIN_409");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement"), any(Object[].class));
}
@Test
void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() {
for (String condition : List.of("supplier", "ineligible", "occupied")) {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
Map<String, Object> source = payableSource(10L, "supplier".equals(condition) ? 21L : 20L);
if ("ineligible".equals(condition)) source.put("review_status", "PENDING");
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-" + condition))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source));
org.mockito.Mockito.lenient().when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L)))
.thenReturn("occupied".equals(condition) ? 1 : 0);
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createPayableSettlement(7L, "ap-" + condition,
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_409");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
}
@Test
void settlementDetailRejectsMissingResource() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(999L))).thenReturn(List.of());
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).getSettlement(999L), "FIN_404");
}
@Test
void finalPaymentMarksSettlementPaid() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class);
Map<String, Object> before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), new BigDecimal("60.00"), 1);
Map<String, Object> after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-key"))).thenReturn(List.of());
stubSettlementDetail(jdbc, after);
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, fileClient, supplierValidator);
SettlementDetail result = service.createSettlementPayment(88L, 7L, "final-key", payment(new BigDecimal("60.00"), 1));
assertThat(result.summary().status()).isEqualTo("PAID");
verify(supplierValidator).requireActive(20L, "BOOKING");
assertThat(result.summary().outstandingAmount()).isZero();
}
@Test
void finalPaymentReplayReturnsFirstResultWithoutDuplicatePayment() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class);
Map<String, Object> before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 1);
Map<String, Object> after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L)))
.thenReturn(List.of(before), List.of(after));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of());
stubSettlementDetail(jdbc, after);
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSettlementService service = service(jdbc, fileClient);
SettlementDetail first = service.createSettlementPayment(88L, 7L, "final-replay-key",
payment(new BigDecimal("60.00"), 1));
ArgumentCaptor<String> requestHash = ArgumentCaptor.forClass(String.class);
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"), requestHash.capture());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of(Map.of(
"request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L,
"updated_at", LocalDateTime.now())));
SettlementDetail replay = service.createSettlementPayment(88L, 7L, "final-replay-key",
payment(new BigDecimal("60.00"), 1));
assertThat(first.summary().status()).isEqualTo("PAID");
assertThat(replay.summary().status()).isEqualTo("PAID");
assertThat(replay.summary().outstandingAmount()).isZero();
long paymentInserts = org.mockito.Mockito.mockingDetails(jdbc).getInvocations().stream()
.filter(invocation -> invocation.getMethod().getName().equals("update")
&& invocation.getArguments().length > 0
&& String.valueOf((Object) invocation.getArgument(0)).startsWith("INSERT INTO fin_settlement_payment"))
.count();
assertThat(paymentInserts).isEqualTo(1);
}
@Test
void receivableSettlementDetailIncludesAppliedReceiptHistory() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
Map<String, Object> settlement = settlementRow("RECEIVABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 1);
settlement.put("reconciliation_statement_id", 501L);
stubSettlementDetail(jdbc, settlement);
when(jdbc.queryForList(startsWith("SELECT r.id,'RECEIPT'"), eq(10L), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 701L), Map.entry("transaction_type", "RECEIPT"),
Map.entry("transaction_date", Date.valueOf("2026-08-11")), Map.entry("amount", new BigDecimal("40.00")),
Map.entry("method", "BANK_TRANSFER"), Map.entry("reference_no", "RCPT-701"),
Map.entry("voucher_file_ids", "[\"91\"]"), Map.entry("created_by", 7L),
Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf("2026-08-11 10:00:00")), Map.entry("remark", "首笔收款"))));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.getSettlement(88L);
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("RECEIPT");
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
}
@Test
void settlementIdempotencyReplaysRejectsMismatchAndRecoversInterruptedRecords() {
Map<String, Object> body = Map.of("customerId", 10L, "amount", "100.00");
JdbcTemplate firstJdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(firstJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("idem-key"))).thenReturn(List.of());
FinanceSettlementService firstService = service(firstJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", body);
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
verify(firstJdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L),
eq("SETTLEMENT_AP_CREATE"), eq("idem-key"), hash.capture());
assertThat(first).isNull();
JdbcTemplate replayJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(replayJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("idem-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L,
"updated_at", LocalDateTime.now())));
FinanceSettlementService replayService = service(replayJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertThat((Long) ReflectionTestUtils.invokeMethod(replayService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", body)).isEqualTo(88L);
assertCode(() -> ReflectionTestUtils.invokeMethod(replayService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", Map.of("customerId", 10L, "amount", "101.00")), "FIN_409");
JdbcTemplate processingJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(processingJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("processing-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
assertCode(() -> ReflectionTestUtils.invokeMethod(service(processingJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)),
"reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "processing-key", "POST", "/settlements", body), "FIN_409");
JdbcTemplate recoveryJdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(recoveryJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("failed-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
when(recoveryJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("stale-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
FinanceSettlementService recoveryService = service(recoveryJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "failed-key", "POST", "/settlements", body)).isNull();
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "stale-key", "POST", "/settlements", body)).isNull();
JdbcTemplate conflictJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
FinanceSettlementService conflictService = service(conflictJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> ReflectionTestUtils.invokeMethod(conflictService, "complete", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", 88L), "FIN_409");
}
@Test
void receivableCreationRejectsAnExistingActiveSettlement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"),
Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"),
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createReceivableSettlement(7L, "ar-key", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void payableCreationRejectsMixedSupplierAndOccupiedSources() {
JdbcTemplate mixedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(mixedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("mixed-key"))).thenReturn(List.of());
when(mixedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 21L)));
FinanceSettlementService mixedService = service(mixedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
CreatePayableSettlementRequest request = new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY");
assertCode(() -> mixedService.createPayableSettlement(7L, "mixed-key", request), "FIN_409");
JdbcTemplate occupiedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(occupiedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("occupied-key"))).thenReturn(List.of());
when(occupiedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(occupiedJdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(1);
FinanceSettlementService occupiedService = service(occupiedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> occupiedService.createPayableSettlement(7L, "occupied-key", request), "FIN_409");
}
@Test
void paymentRejectsAmountAboveOutstandingBeforeVoucherWrite() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createSettlementPayment(88L, 7L, "pay-key", payment(new BigDecimal("100.01"), 0)), "FIN_409");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
return service(jdbc, fileClient, supplierValidator());
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient,
FinanceSupplierValidator supplierValidator) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001");
// 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。
var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> {
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
if (select == null) return RETURNS_DEFAULTS.answer(invocation);
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
return jdbc.queryForList(sql, invocation.getArguments());
});
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
}
private static FinanceSupplierValidator supplierValidator() {
FinanceSupplierValidator supplierValidator = org.mockito.Mockito.mock(FinanceSupplierValidator.class);
org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any()))
.thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
20L, "供应商20", "BOOKING", "ACTIVE"));
return supplierValidator;
}
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
return new CreateSettlementPaymentRequest(LocalDate.of(2026, 8, 11), amount, "CNY", "BANK_TRANSFER",
"BANK-1", List.of("91"), version, "首笔付款");
}
private static JdbcTemplate creationJdbc(Map<String, Object> settlement) {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class,
org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)));
org.mockito.Mockito.lenient().doAnswer(invocation -> {
KeyHolder holder = invocation.getArgument(1);
holder.getKeyList().add(Map.of("GENERATED_KEY", 88L));
return 1;
}).when(jdbc).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
stubSettlementDetail(jdbc, settlement);
return jdbc;
}
private static void stubSettlementDetail(JdbcTemplate jdbc, Map<String, Object> settlement) {
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
long customerId = ((Number) settlement.get("tenant_id")).longValue();
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
}
private static Map<String, Object> payableSource(long customerId, long supplierId) {
return new java.util.HashMap<>(Map.ofEntries(
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_mode", "ON_ACCOUNT"),
Map.entry("supplier_id", supplierId),
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
}
private static Map<String, Object> settlementRow(String direction, String status, BigDecimal paid,
BigDecimal outstanding, int version) {
java.util.HashMap<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("id", 88L), Map.entry("tenant_id", 10L), Map.entry("settlement_no", "AP-20260811-001"),
Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"),
Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"),
Map.entry("business_period", "2026-08"), Map.entry("fee_item_summary", "拖车费"),
Map.entry("currency_code", "CNY"),
Map.entry("total_amount", paid.add(outstanding)), Map.entry("paid_amount", paid),
Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")),
Map.entry("due_date", Date.valueOf("2026-09-10")),
Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status),
Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"),
Map.entry("created_at", Timestamp.valueOf("2026-08-11 09:00:00")),
Map.entry("updated_at", Timestamp.valueOf("2026-08-11 09:00:00")), Map.entry("version", version)));
row.put("reconciliation_statement_id", null);
row.put("reconciliation_statement_no", null);
row.put("last_payment_at", null);
return row;
}
private static SettlementDetail detail(String direction, String status, BigDecimal paid,
BigDecimal outstanding, int version) {
return new SettlementDetail(new SettlementSummary(88L, "SET-88", direction, 10L, "客户10", 20L,
"供应商20", null, null, "2026-08", "拖车费", "CNY", paid.add(outstanding), paid, outstanding,
LocalDate.of(2026, 8, 11), LocalDate.of(2026, 9, 10), paid.signum() == 0 ? "UNPAID" : status,
status, 7L, "platform_admin", LocalDateTime.of(2026, 8, 11, 9, 0),
LocalDateTime.of(2026, 8, 11, 9, 0), null, version), List.of(), List.of(), List.of());
}
private static AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, null, null, null, "finance", "ADMIN", "WEB", permissions);
}
private static void assertCode(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, String code) {
assertThatThrownBy(call).isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo(code);
}
}