This commit is contained in:
2026-08-19 09:14:30 +08:00
parent 8845a66749
commit ff1d330c2d
91 changed files with 1538 additions and 523 deletions
@@ -52,7 +52,7 @@ class CustomerAccountSummaryBehaviorTest {
.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.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.settlement_included=1")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
.contains("b.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
@@ -24,10 +24,11 @@ class FinanceContractTest {
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
assertTrue(service.contains("validateBillSources(tenantId, request.lines())"));
assertTrue(service.contains("账单金额必须等于费用来源金额"));
assertTrue(service.contains("source_fee_version") && service.contains("settlement_included"));
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
assertFalse(controller.contains("@PostMapping(\"/bills\")"));
assertFalse(controller.contains("/bills/{billId}/issue"));
assertFalse(service.contains("BILL_CREATE"));
assertFalse(service.contains("BILL_ISSUE"));
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
@@ -38,7 +39,6 @@ class FinanceContractTest {
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance"));
assertTrue(unified.contains("assert_finance_permissions"));
assertTrue(unified.contains("platform:finance:bill:issue"));
assertTrue(unified.contains("merchant:finance:statement:view"));
}
}
@@ -37,7 +37,6 @@ class FinanceFeeSourceEventServiceTest {
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("TRUCKING");
lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE"));
@@ -71,20 +70,19 @@ class FinanceFeeSourceEventServiceTest {
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有往来单位",
61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", true,
"TRUCKING", 61L, "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-payable", 1);
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).categoryFor("TRUCKING", "拖车费");
verify(supplierValidator).requireActive(71L, "TRUCKING");
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
.contains("\"partnerId\":71", "\"partnerName\":\"现有往来单位\"");
.contains("\"supplierId\":71", "\"supplierName\":\"现有往来单位\"", "\"supplierCategory\":\"TRUCKING\"");
}
@Test
@@ -202,9 +200,9 @@ class FinanceFeeSourceEventServiceTest {
String publicationStatus, BigDecimal amount) {
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
inboundId == null ? null : "IB-21", 51L, containerId, null,
containerId == null ? null : "C-41", customerId, "客户", null, null, 61L, "CUSTOMS",
containerId == null ? null : "C-41", customerId, "客户", null, null, null, 61L, "CUSTOMS",
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
"APPROVED", publicationStatus, true, LocalDateTime.of(2026, 8, 11, 1, 0),
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
}
@@ -48,23 +48,23 @@ class FinancePhase1OperationBehaviorTest {
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService);
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0);
var inclusion = new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入本期结算", 1);
var action = new FinancePhase1Dtos.FeeSourceActionRequest("提交审核", 1);
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED", true,
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion);
controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
eq("AVAILABLE"), eq("APPROVED"), eq(true), eq(false), any(), any(), eq("IN-9"));
eq("AVAILABLE"), eq("APPROVED"), eq(false), any(), any(), eq("IN-9"));
verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
verify(operationService).updateFeeSourceSettlementInclusion(88L, 71L, "include-key", inclusion);
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
}
@Test
@@ -141,14 +141,13 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("settlement_included")).thenReturn(true);
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
});
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
true, false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page()).satisfies(meta -> {
assertThat(meta.page()).isEqualTo(2);
@@ -163,7 +162,6 @@ class FinancePhase1OperationBehaviorTest {
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
assertThat(source.settlementIncluded()).isTrue();
assertThat(source.statementGenerated()).isFalse();
});
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
@@ -201,14 +199,11 @@ class FinancePhase1OperationBehaviorTest {
}
@Test
void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
assertThatThrownBy(() -> service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-FEE-409");
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
}
@Test
@@ -222,33 +217,32 @@ class FinancePhase1OperationBehaviorTest {
assertThat(detail.customerId()).isEqualTo(9L);
assertThat(detail.currency()).isEqualTo("CNY");
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
assertThat(detail.settlementIncluded()).isTrue();
}
@Test
void approvedFeeCanBeIncludedWithExpectedVersion() throws Exception {
void rejectedFeeCanBeEditedBackToDraftWithExpectedVersion() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName())
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
AtomicInteger reads = new AtomicInteger();
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
boolean included = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet("APPROVED", included, included ? 2 : 1), 0));
boolean changed = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet(changed ? "DRAFT" : "REJECTED", false, changed ? 2 : 1), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
eq("FEE_SOURCE_EDIT"), eq("edit-key"))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FeeSourceDetail result = service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 1));
FeeSourceDetail result = service.editFinanceFeeSource(88L, 71L, "edit-key",
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "更正金额", 1), false);
assertThat(result.settlementIncluded()).isTrue();
assertThat(result.reviewStatus()).isEqualTo("DRAFT");
assertThat(result.operationVersion()).isEqualTo(2);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET settlement_included="),
eq(true), eq("进入结算"), eq(88L), eq(1));
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET amount="),
eq(new BigDecimal("120.00")), eq(88L), eq(1), eq("REJECTED"));
}
@Test
@@ -316,11 +310,9 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("source_hash")).thenReturn("a".repeat(64));
when(rs.getString("review_status")).thenReturn(reviewStatus);
when(rs.getString("review_reason")).thenReturn(null);
when(rs.getString("settlement_reason")).thenReturn(null);
when(rs.getBoolean("settlement_included")).thenReturn(included);
when(rs.getInt("operation_version")).thenReturn(version);
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"settlementIncluded\":" + included + ",\"operationVersion\":" + version + "}");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
return rs;
}
@@ -27,10 +27,10 @@ class FinancePhase1PermissionContractTest {
"platform:finance:fee-source:review",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
"platform:finance:fee-source:settlement",
assertEndpoint("editFinanceFeeSource", PutMapping.class,
"platform:finance:fee-source:edit",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest.class);
}
private static void assertEndpoint(String methodName,
@@ -11,11 +11,14 @@ import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
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.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -34,6 +37,7 @@ class FinanceReadApiBehaviorTest {
@Mock FinanceFileClient fileClient;
@Mock FirstlegCostSnapshotClient snapshotClient;
@Mock FinanceSettlementService settlementService;
@Mock ReconciliationStatementService reconciliationStatementService;
@Test
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
@@ -42,7 +46,7 @@ class FinanceReadApiBehaviorTest {
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
var response = new ClientFinanceController(service, queryService).receipts(user);
var response = new ClientFinanceController(service, queryService, reconciliationStatementService).receipts(user);
verify(queryService).clientReceipts(23L);
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
@@ -50,7 +54,7 @@ class FinanceReadApiBehaviorTest {
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
new ClientFinanceController(service, queryService).bill(user, 88L);
new ClientFinanceController(service, queryService, reconciliationStatementService).bill(user, 88L);
verify(queryService).statement(23L, 88L, true);
}
@@ -102,7 +106,7 @@ class FinanceReadApiBehaviorTest {
@Test
void clientListsAlwaysUseAuthenticatedCustomerScope() {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
var controller = new ClientFinanceController(service, queryService);
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
controller.bills(user);
controller.statements(user);
@@ -112,4 +116,23 @@ class FinanceReadApiBehaviorTest {
verify(queryService).receivableSettlements(23L, true);
verifyNoMoreInteractions(queryService);
}
@Test
void clientConfirmationUsesOnlyAuthenticatedCustomerAndOperatorScope() {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
CustomerConfirmationRequest request = new CustomerConfirmationRequest(4);
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
controller.confirmStatement(user, 88L, "confirm-key", request);
verify(reconciliationStatementService).confirmForCustomer(23L, 7L, 88L, "confirm-key", request);
}
@Test
void clientConfirmationHasIndependentWriteAuthority() throws Exception {
PreAuthorize rule = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class,
long.class, String.class, CustomerConfirmationRequest.class).getAnnotation(PreAuthorize.class);
assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')");
}
}
@@ -10,13 +10,10 @@ import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
@@ -25,45 +22,6 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class FinanceServiceValidationTest {
@Test
void acceptsOnlyApprovedIncludedReceivableSourceAtItsRecordedAmount() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class),
eq(9L), eq(81L), eq(3))).thenReturn(0);
FinanceService service = service(jdbc);
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "validateBillSources", 9L,
List.of(billLine(81L, 3, "125.50"))));
}
@Test
void rejectsClientAmountThatDiffersFromFinanceSourceSnapshot() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
FinanceService service = service(jdbc);
BizException error = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
"validateBillSources", 9L, List.of(billLine(81L, 3, "99.00"))));
assertEquals("账单金额必须等于费用来源金额", error.getMessage());
verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"),
eq(Integer.class), any(), any(), any());
}
@Test
void rejectsSourceThatIsNotApprovedAndIncludedForSettlement() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "PENDING", false, "125.50")));
FinanceService service = service(jdbc);
assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
"validateBillSources", 9L, List.of(billLine(81L, 3, "125.50"))));
}
@Test
void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() {
var rows = List.of(
@@ -284,15 +242,4 @@ class FinanceServiceValidationTest {
mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class));
}
private static FinanceService.BillLine billLine(long sourceId, int version, String amount) {
return new FinanceService.BillLine("FIRSTLEG_FEE", sourceId, version, null, 31L, 41L,
"FREIGHT", "运费", new BigDecimal(amount));
}
private static Map<String, Object> source(long customerId, int version, String status,
String reviewStatus, boolean included, String amount) {
return Map.of("customer_id", customerId, "source_fee_version", version,
"direction", "RECEIVABLE", "currency_code", "CNY", "amount", new BigDecimal(amount),
"status", status, "review_status", reviewStatus, "settlement_included", included);
}
}
@@ -13,6 +13,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
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;
@@ -542,8 +543,11 @@ class FinanceSettlementBehaviorTest {
FinanceSupplierValidator supplierValidator) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT f.fee_item_code"), anyLong(), anyLong()))
.thenReturn(List.of(Map.of("fee_item_code", "FBA_HEAD_FREIGHT", "fee_item_name", "头程运费")));
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator);
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
}
private static FinanceSupplierValidator supplierValidator() {
@@ -586,7 +590,7 @@ class FinanceSettlementBehaviorTest {
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_included", "true"), Map.entry("supplier_id", supplierId),
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\"}")));
@@ -43,7 +43,7 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(16);
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(239);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -18,12 +18,9 @@ class FinanceSupplierValidatorTest {
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
@Test
void resolvesSupportedFeeCategories() {
assertThat(validator.categoryFor("TRUCKING", "拖车费")).isEqualTo("TRUCKING");
assertThat(validator.categoryFor("CUSTOMS_CLEARANCE", "报关费")).isEqualTo("CUSTOMS");
assertThat(validator.categoryFor("FBA_HEAD_FREIGHT", "头程运费")).isEqualTo("BOOKING");
assertThat(validator.categoryFor("CERTIFICATE_FEE", "证书费")).isEqualTo("CERTIFICATE");
assertCode(() -> validator.categoryFor("ACPT_UNKNOWN", "操作费"), "FIN_409");
void rejectsMissingOrInvalidExplicitFeeCategory() {
assertCode(() -> validator.requireActive(20L, null), "FIN_409");
assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409");
}
@Test
@@ -0,0 +1,39 @@
package com.baiyue.finance;
import com.baiyue.finance.controller.ReceivableStatisticsController;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.ReceivableStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ReceivableStatisticsControllerTest {
@Test
void exposesTheDedicatedReadEndpointAndRejectsNonMonthInput() throws Exception {
ReceivableStatisticsService service = mock(ReceivableStatisticsService.class);
ReceivableStatisticsResponse response = new ReceivableStatisticsResponse(YearMonth.of(2026, 7), "CNY",
BigDecimal.TEN, BigDecimal.ONE, BigDecimal.valueOf(9), 0, 1, 20, List.of());
when(service.query(YearMonth.of(2026, 7), null, 1, 20)).thenReturn(response);
ReceivableStatisticsController controller = new ReceivableStatisticsController(service);
assertThat(ReceivableStatisticsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/finance/v1/receivable-statistics");
assertThat(ReceivableStatisticsController.class.getMethod("query", String.class, Long.class, int.class, int.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:receivable-statistics:list')");
assertThat(controller.query("2026-07", null, 1, 20).data()).isSameAs(response);
verify(service).query(YearMonth.of(2026, 7), null, 1, 20);
assertThatThrownBy(() -> controller.query("2026/07", null, 1, 20))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("yyyy-MM");
}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.ReceivableStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.time.YearMonth;
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;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ReceivableStatisticsServiceTest {
@Test
@SuppressWarnings("unchecked")
void aggregatesOnlyTheApprovedFormalCnyDimensionsAndPaginatesCustomers() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
List<ReceivableStatisticsRow> rows = List.of(
row(10001, "客户A", "300.00", "100.00", "200.00"),
row(10002, "客户B", "80.00", "80.00", "0.00"));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows);
ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc);
var result = service.query(YearMonth.of(2026, 7), null, 1, 1);
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.totalReceivable()).isEqualByComparingTo("380.00");
assertThat(result.totalReceived()).isEqualByComparingTo("180.00");
assertThat(result.totalOutstanding()).isEqualByComparingTo("200.00");
assertThat(result.totalCustomers()).isEqualTo(2);
assertThat(result.records()).containsExactly(rows.get(0));
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class));
assertThat(sql.getValue()).contains("direction='RECEIVABLE'", "status<>'VOIDED'", "currency_code='CNY'",
"generated_date>=?", "generated_date<?", "GROUP BY tenant_id,customer_name_snapshot");
}
@Test
@SuppressWarnings("unchecked")
void failsClosedWhenPersistedTotalsDoNotReconcile() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99")));
assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc)
.query(YearMonth.of(2026, 7), null, 1, 20))
.isInstanceOf(FinanceEventException.class)
.hasMessageContaining("勾稽异常");
}
private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) {
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
new BigDecimal(received), new BigDecimal(outstanding));
}
}
@@ -3,9 +3,11 @@ package com.baiyue.finance;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.controller.ReconciliationStatementController;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -27,6 +29,7 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -80,7 +83,7 @@ class ReconciliationStatementBehaviorTest {
@Test
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
@@ -95,14 +98,14 @@ class ReconciliationStatementBehaviorTest {
@Test
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
var result = target.create(71L, "create-key", request);
assertThat(result.statementId()).isEqualTo(501L);
assertThat(result.statementNo()).isEqualTo("REC-00000000000000000501");
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.createdByName()).isEqualTo("platform_admin");
assertThat(result.updatedByName()).isEqualTo("platform_admin");
@@ -181,9 +184,99 @@ class ReconciliationStatementBehaviorTest {
assertThat(result.status()).isEqualTo("ISSUED");
assertThat(result.version()).isEqualTo(3);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION'"), eq(501L), eq(9L));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
}
@Test
void customerConfirmationChecksScopeStateVersionAndWritesAudit() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
AtomicInteger billReads = new AtomicInteger();
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
boolean confirmed = billReads.getAndIncrement() > 0;
return List.of(mapper.mapRow(billResultSet("ISSUED", confirmed ? 4 : 3,
confirmed ? "CONFIRMED" : "PENDING_CONFIRMATION", confirmed ? 71L : null), 0));
});
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
assertThat(result.confirmationStatus()).isEqualTo("CONFIRMED");
assertThat(result.confirmedBy()).isEqualTo(71L);
assertThat(result.version()).isEqualTo(4);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"),
eq(9L), eq("RECONCILIATION_STATEMENT"), eq(501L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), isNull(),
anyString(), anyString(), eq(71L), eq("操作人 71"), isNull(), isNull());
ArgumentCaptor<String> requestHash = ArgumentCaptor.forClass(String.class);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"), requestHash.capture());
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of(Map.of(
"request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 501L,
"updated_at", LocalDateTime.now())));
var replay = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
assertThat(replay.confirmationStatus()).isEqualTo("CONFIRMED");
verify(jdbc, org.mockito.Mockito.times(1)).update(
org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
}
@Test
void customerConfirmationHidesCrossCustomerAndRejectsInvalidState() throws Exception {
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
verify(crossCustomer, never()).queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), (Object[]) any());
JdbcTemplate draft = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(draft.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet("DRAFT", 0, "NOT_REQUIRED", null), 0));
});
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(draft, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"), (Object[]) any());
JdbcTemplate stale = mock(JdbcTemplate.class, invocation -> {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) {
Object sql = invocation.getArguments()[0];
return sql instanceof String value && value.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'") ? 0 : 1;
}
return RETURNS_DEFAULTS.answer(invocation);
});
when(stale.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet("ISSUED", 3, "PENDING_CONFIRMATION", null), 0));
});
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
}
@Test
void sameIdempotencyKeyWithDifferentActionBodyIsRejected() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -456,16 +549,24 @@ class ReconciliationStatementBehaviorTest {
}
private static ResultSet billResultSet(String status, int version) throws Exception {
return billResultSet(status, version, "ISSUED".equals(status) ? "PENDING_CONFIRMATION" : "NOT_REQUIRED", null);
}
private static ResultSet billResultSet(String status, int version, String confirmationStatus, Long confirmedBy) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(501L);
when(rs.getLong("tenant_id")).thenReturn(9L);
when(rs.getString("bill_no")).thenReturn("REC-00000000000000000501");
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
when(rs.getString("status")).thenReturn(status);
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
when(rs.getBigDecimal("received_amount")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("300.00"));
when(rs.getInt("version")).thenReturn(version);
when(rs.getString("customer_confirmation_status")).thenReturn(confirmationStatus);
when(rs.getLong("customer_confirmed_by")).thenReturn(confirmedBy == null ? 0L : confirmedBy);
when(rs.wasNull()).thenReturn(confirmedBy == null);
when(rs.getString("customer_confirmed_by_name")).thenReturn(confirmedBy == null ? null : "操作人 71");
when(rs.getInt("credit_days")).thenReturn(30);
when(rs.getString("remark")).thenReturn("八月对账");
when(rs.getString("created_by_name")).thenReturn("platform_admin");
@@ -473,6 +574,7 @@ class ReconciliationStatementBehaviorTest {
Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00");
when(rs.getTimestamp("created_at")).thenReturn(timestamp);
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
when(rs.getTimestamp("customer_confirmed_at")).thenReturn(confirmedBy == null ? null : timestamp);
return rs;
}
@@ -486,4 +588,10 @@ class ReconciliationStatementBehaviorTest {
default -> 1;
};
}
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
}
}
@@ -0,0 +1,46 @@
package com.baiyue.finance.service;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void formatsShortDailySequenceForAllThreeDocuments() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
eq("REC:20260818"));
}
@Test
void rejectsUnsupportedTypeAndOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("REC"))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
}
}
@@ -8,6 +8,7 @@ import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -18,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.List;
import java.util.Map;
@@ -33,12 +35,13 @@ class FinanceExportProviderServiceTest {
@Mock FinanceQueryService queryService;
@Mock ReconciliationStatementService reconciliationService;
@Mock FinanceSettlementService settlementService;
@Mock ReceivableStatisticsService receivableStatisticsService;
private FinanceExportProviderService service;
@BeforeEach
void setUp() {
service = new FinanceExportProviderService(accountService, operationService, queryService,
reconciliationService, settlementService, new ObjectMapper(), "12345678901234567890123456789012");
reconciliationService, settlementService, receivableStatisticsService, new ObjectMapper(), "12345678901234567890123456789012");
service.initializeSigningKey();
}
@@ -46,6 +49,7 @@ class FinanceExportProviderServiceTest {
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3,
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"),
7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"));
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE",
"CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN,
@@ -53,14 +57,15 @@ class FinanceExportProviderServiceTest {
new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整");
ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A",
LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"),
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3, 7L, "创建人A",
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3,
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"), 7L, "创建人A",
LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"),
"八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"),
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED", true,
"AVAILABLE", true, null, null, "hash", 2, List.of());
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED",
"AVAILABLE", true, null, "hash", 2, List.of());
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
when(operationService.getFinanceFeeSource(81L)).thenReturn(source);
@@ -107,6 +112,23 @@ class FinanceExportProviderServiceTest {
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null);
}
@Test
void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() {
when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of(
new ReceivableStatisticsRow(10001L, "客户A", "CNY", new BigDecimal("300"),
new BigDecimal("100"), new BigDecimal("200"))));
var result = service.exportBatch(FinanceExportProviderService.RECEIVABLE_STATISTICS,
request(Map.of("month", "2026-07", "customerId", "10001"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> assertThat(row)
.containsEntry("statisticsMonth", "2026-07")
.containsEntry("customerName", "客户A")
.containsEntry("currency", "CNY")
.doesNotContainKeys("fbaDirect", "fbaSpecialLine", "roundingDiscount"));
verify(receivableStatisticsService).exportRows(YearMonth.of(2026, 7), 10001L);
}
private ExportBatchRequest request(Map<String, Object> parameters, String cursor, String snapshot) {
return request(parameters, cursor, snapshot, 100);
}