724 lines
48 KiB
Java
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);
|
|
}
|
|
}
|