优化
This commit is contained in:
@@ -92,13 +92,84 @@ class FinanceServiceValidationTest {
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(
|
||||
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L)));
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
||||
var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||
assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void receiptAllocationUpdatesLinkedReceivableSettlement() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class
|
||||
? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
|
||||
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 9L)));
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("RECEIPT_CREATE"), eq("receipt-key"))).thenReturn(List.of());
|
||||
when(jdbc.queryForMap(startsWith("SELECT outstanding_amount"), eq(50L), eq(9L))).thenReturn(
|
||||
java.util.Map.of("outstanding_amount", new BigDecimal("100.00"), "status", "ISSUED"));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Integer.class), eq(9L), eq(50L))).thenReturn(1);
|
||||
when(jdbc.queryForMap(startsWith("SELECT * FROM fin_receipt WHERE id="), eq(701L))).thenReturn(java.util.Map.of("id", 701L));
|
||||
doAnswer(invocation -> {
|
||||
org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1);
|
||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||
return 1;
|
||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
||||
var request = new FinanceService.ReceiptRequest("R-4", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
||||
"BANK_TRANSFER", null, List.of("77"), "首笔收款",
|
||||
List.of(new FinanceService.ReceiptAllocation(50L, new BigDecimal("40.00"))));
|
||||
|
||||
service.createReceipt(9L, 2L, request, "receipt-key");
|
||||
|
||||
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount="),
|
||||
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")),
|
||||
eq(new BigDecimal("40.00")), eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void receiptAllocatesThreeHundredAcrossThreeIssuedBills() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class
|
||||
? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||
FinanceFileClient files = mock(FinanceFileClient.class);
|
||||
when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
|
||||
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 9L)));
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("RECEIPT_CREATE"), eq("receipt-three-key"))).thenReturn(List.of());
|
||||
for (long billId : List.of(50L, 51L, 52L)) {
|
||||
when(jdbc.queryForMap(startsWith("SELECT outstanding_amount"), eq(billId), eq(9L))).thenReturn(
|
||||
java.util.Map.of("outstanding_amount", new BigDecimal("100.00"), "status", "ISSUED"));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Integer.class), eq(9L), eq(billId))).thenReturn(0);
|
||||
}
|
||||
when(jdbc.queryForMap(startsWith("SELECT * FROM fin_receipt WHERE id="), eq(701L))).thenReturn(
|
||||
java.util.Map.of("id", 701L, "received_amount", new BigDecimal("300.00")));
|
||||
doAnswer(invocation -> {
|
||||
org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1);
|
||||
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
|
||||
return 1;
|
||||
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "fileClient", files);
|
||||
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
|
||||
var request = new FinanceService.ReceiptRequest("R-300", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
|
||||
"BANK_TRANSFER", "BANK-300", List.of("77"), "三单收款", List.of(
|
||||
new FinanceService.ReceiptAllocation(50L, new BigDecimal("100.00")),
|
||||
new FinanceService.ReceiptAllocation(51L, new BigDecimal("100.00")),
|
||||
new FinanceService.ReceiptAllocation(52L, new BigDecimal("100.00"))));
|
||||
|
||||
var result = service.createReceipt(9L, 2L, request, "receipt-three-key");
|
||||
|
||||
assertEquals(new BigDecimal("300.00"), result.get("received_amount"));
|
||||
for (long billId : List.of(50L, 51L, 52L)) {
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_receipt_bill"), eq(9L), eq(701L), eq(billId),
|
||||
eq(new BigDecimal("100.00")), eq(2L));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSingleOrderWhenOrderNotOwnedByCustomer() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
|
||||
@@ -17,6 +17,8 @@ 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;
|
||||
|
||||
@@ -35,9 +37,11 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.RETURNS_DEFAULTS;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FinanceSettlementBehaviorTest {
|
||||
@@ -99,6 +103,25 @@ class FinanceSettlementBehaviorTest {
|
||||
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 partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
|
||||
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
|
||||
@@ -136,6 +159,232 @@ class FinanceSettlementBehaviorTest {
|
||||
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 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);
|
||||
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
|
||||
|
||||
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(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 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 id,tenant_id,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)));
|
||||
FinanceSettlementService service = service(jdbc, fileClient);
|
||||
|
||||
SettlementDetail result = service.createSettlementPayment(88L, 7L, "final-key", payment(new BigDecimal("60.00"), 1));
|
||||
|
||||
assertThat(result.summary().status()).isEqualTo("PAID");
|
||||
assertThat(result.summary().outstandingAmount()).isZero();
|
||||
}
|
||||
|
||||
@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_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");
|
||||
}
|
||||
|
||||
@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) {
|
||||
FinanceSettlementService service = new FinanceSettlementService(
|
||||
jdbc, new ObjectMapper().findAndRegisterModules(), fileClient);
|
||||
@@ -148,6 +397,37 @@ class FinanceSettlementBehaviorTest {
|
||||
"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 id,tenant_id,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_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_included", "true"), Map.entry("supplier_id", supplierId),
|
||||
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) {
|
||||
return new java.util.HashMap<>(Map.ofEntries(
|
||||
@@ -155,7 +435,7 @@ class FinanceSettlementBehaviorTest {
|
||||
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("currency_code", "CNY"),
|
||||
Map.entry("total_amount", new BigDecimal("100.00")), Map.entry("paid_amount", paid),
|
||||
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),
|
||||
|
||||
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(13);
|
||||
assertThat(columnCount).isEqualTo(184);
|
||||
assertThat(columnCount).isEqualTo(190);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
@@ -57,4 +57,17 @@ class FinanceSqlCommentContractTest {
|
||||
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
|
||||
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationAddsBillLineAdjustmentReasonForNewAndExistingSchemas() throws Exception {
|
||||
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
String sql = Files.readString(unified, StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'")
|
||||
.contains("table_schema='baiyue_finance' AND table_name='fin_bill_line' AND column_name='adjustment_reason'")
|
||||
.contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason")
|
||||
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
|
||||
}
|
||||
}
|
||||
|
||||
+245
-18
@@ -3,18 +3,21 @@ 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.DocumentActionRequest;
|
||||
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.ReconciliationStatementService;
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.PreparedStatementCreator;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
@@ -45,9 +48,9 @@ class ReconciliationStatementBehaviorTest {
|
||||
void controllerUsesAuthenticatedOperatorAndStableTraceResponse() {
|
||||
ReconciliationStatementController controller = new ReconciliationStatementController(service);
|
||||
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
|
||||
CreateReconciliationRequest create = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L),
|
||||
CreateReconciliationRequest create = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
|
||||
DocumentActionRequest action = new DocumentActionRequest("SUBMIT", "提交审核", 0);
|
||||
ReconciliationActionRequest action = new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of());
|
||||
|
||||
var created = controller.createReconciliationStatement(user, "create-key", create);
|
||||
var acted = controller.actionReconciliationStatement(user, 501L, "action-key", action);
|
||||
@@ -64,20 +67,35 @@ class ReconciliationStatementBehaviorTest {
|
||||
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L, 88L),
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
||||
|
||||
assertThatThrownBy(() -> target.create(71L, "create-key", request))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_400");
|
||||
verifyNoInteractions(jdbc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
|
||||
JdbcTemplate jdbc = createJdbc();
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
|
||||
|
||||
var result = target.create(71L, "cutoff-key", request);
|
||||
|
||||
assertThat(result.status()).isEqualTo("DRAFT");
|
||||
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00");
|
||||
verify(jdbc).query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class),
|
||||
eq(9L), eq(LocalDate.of(2026, 8, 1)), eq(LocalDate.of(2026, 8, 31)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
|
||||
JdbcTemplate jdbc = createJdbc();
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L),
|
||||
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);
|
||||
@@ -90,13 +108,40 @@ class ReconciliationStatementBehaviorTest {
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
|
||||
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1;
|
||||
return RETURNS_DEFAULTS.answer(invocation);
|
||||
});
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of());
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"),
|
||||
any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id FROM fin_subject"), any(RowMapper.class))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getLong(1)).thenReturn(1L);
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
||||
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
|
||||
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
||||
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "submit-key",
|
||||
new DocumentActionRequest("SUBMIT", "提交审核", 0));
|
||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
|
||||
|
||||
assertThat(result.customerId()).isEqualTo(9L);
|
||||
assertThat(result.status()).isEqualTo("PENDING_REVIEW");
|
||||
@@ -112,14 +157,27 @@ class ReconciliationStatementBehaviorTest {
|
||||
|
||||
@Test
|
||||
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "ISSUED", "approve-key");
|
||||
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "approve-key",
|
||||
new DocumentActionRequest("APPROVE", "审核通过", 1));
|
||||
new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
|
||||
|
||||
assertThat(result.status()).isEqualTo("APPROVED");
|
||||
assertThat(result.version()).isEqualTo(2);
|
||||
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "issue-key",
|
||||
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
|
||||
|
||||
assertThat(result.status()).isEqualTo("ISSUED");
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
||||
}
|
||||
|
||||
@@ -137,30 +195,178 @@ class ReconciliationStatementBehaviorTest {
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
|
||||
new DocumentActionRequest("SUBMIT", "提交审核", 0)))
|
||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-IDEM-409");
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
|
||||
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_400");
|
||||
assertThatThrownBy(() -> target.action(71L, 501L, "stale-version-key",
|
||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 99, List.of())))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustIssuedStatementUpdatesSelectedLineAndKeepsRevisionHistory() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
||||
.thenReturn(new BigDecimal("280.00"));
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
|
||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
|
||||
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill_line SET amount="),
|
||||
eq(new BigDecimal("280.00")), eq("运费更正"), eq(601L), eq(501L), eq(9L));
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="),
|
||||
eq(new BigDecimal("280.00")), eq(new BigDecimal("280.00")), eq(71L), eq(501L), eq(9L), eq(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentBelowReceivedAmountIsRejected() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "low-adjust-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
||||
.thenReturn(new BigDecimal("80.00"));
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any()))
|
||||
.thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = billResultSet("ISSUED", 3);
|
||||
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal("100.00"));
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
|
||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "void-key",
|
||||
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
|
||||
|
||||
assertThat(result.status()).isEqualTo("VOIDED");
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidRejectsStatementWithReceiptOrValidSettlement() throws Exception {
|
||||
for (String query : List.of("receipt", "settlement")) {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-" + query);
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L)))
|
||||
.thenReturn("receipt".equals(query) ? 1L : 0L);
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
|
||||
.thenReturn("settlement".equals(query) ? 1L : 0L);
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "reopen-key",
|
||||
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
|
||||
|
||||
assertThat(result.status()).isEqualTo("DRAFT");
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
|
||||
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
|
||||
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
|
||||
ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
|
||||
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
|
||||
verify(firstJdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"), hash.capture());
|
||||
assertThat(first).isNull();
|
||||
|
||||
JdbcTemplate replayJdbc = mock(JdbcTemplate.class);
|
||||
when(replayJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("replay-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
|
||||
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(
|
||||
new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
|
||||
.isEqualTo(501L);
|
||||
|
||||
JdbcTemplate processingJdbc = mock(JdbcTemplate.class);
|
||||
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
|
||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
||||
new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
|
||||
JdbcTemplate recoveryJdbc = mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("failed-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
|
||||
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
|
||||
ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
|
||||
}
|
||||
|
||||
private static JdbcTemplate actionJdbc(String beforeStatus, String afterStatus, String key) throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
|
||||
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1;
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation -> {
|
||||
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) {
|
||||
Object[] arguments = invocation.getArguments();
|
||||
if (arguments.length > 1 && arguments[0] instanceof String sql
|
||||
&& sql.startsWith("UPDATE fin_bill SET status=")) {
|
||||
return arguments[arguments.length - 1].equals(versionOf(beforeStatus)) ? 1 : 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return RETURNS_DEFAULTS.answer(invocation);
|
||||
});
|
||||
}));
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||
String sql = invocation.getArgument(0);
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
return List.of(mapper.mapRow(billResultSet(beforeStatus, "PENDING_REVIEW".equals(beforeStatus) ? 1 : 0), 0));
|
||||
return List.of(mapper.mapRow(billResultSet(beforeStatus, versionOf(beforeStatus)), 0));
|
||||
});
|
||||
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);
|
||||
return List.of(mapper.mapRow(billResultSet(afterStatus, "ISSUED".equals(afterStatus) ? 2 : 1), 0));
|
||||
return List.of(mapper.mapRow(billResultSet(afterStatus, versionOf(afterStatus)), 0));
|
||||
});
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT after_data"), any(RowMapper.class), eq(9L), eq(501L)))
|
||||
.thenReturn(List.of("{\"generationMode\":\"SELECTED\",\"periodStart\":\"2026-08-01\",\"periodEnd\":\"2026-08-31\"}"));
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT f.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq(key))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT * FROM fin_bill_line"), eq(9L), eq(501L))).thenReturn(List.of());
|
||||
@@ -168,10 +374,10 @@ class ReconciliationStatementBehaviorTest {
|
||||
}
|
||||
|
||||
private static JdbcTemplate createJdbc() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation -> {
|
||||
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1;
|
||||
return RETURNS_DEFAULTS.answer(invocation);
|
||||
});
|
||||
}));
|
||||
LocalDate start = LocalDate.of(2026, 8, 1);
|
||||
LocalDate end = LocalDate.of(2026, 8, 31);
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
|
||||
@@ -192,6 +398,13 @@ class ReconciliationStatementBehaviorTest {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
return List.of(mapper.mapRow(sourceResultSet(), 0));
|
||||
});
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class),
|
||||
eq(9L), eq(start), eq(end))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
return List.of(mapper.mapRow(sourceResultSet(), 0));
|
||||
});
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
||||
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 9L)));
|
||||
org.mockito.Mockito.lenient().doAnswer(invocation -> {
|
||||
KeyHolder holder = invocation.getArgument(1);
|
||||
holder.getKeyList().add(Map.of("GENERATED_KEY", 501L));
|
||||
@@ -211,8 +424,11 @@ class ReconciliationStatementBehaviorTest {
|
||||
return List.of(mapper.mapRow(sourceResultSet(), 0));
|
||||
});
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("cutoff-key"))).thenReturn(List.of());
|
||||
return jdbc;
|
||||
}
|
||||
|
||||
@@ -254,4 +470,15 @@ class ReconciliationStatementBehaviorTest {
|
||||
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
|
||||
return rs;
|
||||
}
|
||||
|
||||
private static int versionOf(String status) {
|
||||
return switch (status) {
|
||||
case "DRAFT" -> 0;
|
||||
case "PENDING_REVIEW" -> 1;
|
||||
case "APPROVED" -> 2;
|
||||
case "ISSUED" -> 3;
|
||||
case "VOIDED" -> 4;
|
||||
default -> 1;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user