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

391 lines
22 KiB
Java

package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.controller.FinancePhase1OperationController;
import com.baiyue.finance.dto.FinancePhase1Dtos;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
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;
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 static org.mockito.Mockito.mock;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FinancePhase1OperationBehaviorTest {
@Mock FinancePhase1OperationService operationService;
@Mock FinanceCustomerAccountService customerAccountService;
@Test
void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() {
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 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",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
controller.voidFinanceFeeSource(user, 88L, "void-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(false), any(), any(), eq("IN-9"));
verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
verify(operationService).voidFinanceFeeSource(88L, 71L, "finance", "void-key", action);
}
@Test
void pageEnvelopeAlwaysContainsTraceMetadata() {
PageEnvelope<String> response = PageEnvelope.success(List.of("row"), 1, 20, 1);
assertThat(response.code()).isEqualTo("200");
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
assertThat(response.page().total()).isEqualTo(1);
}
@Test
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
}
@Test
void accountListReturnsFixedPagedDto() {
AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
assertThat(result.data()).singleElement().satisfies(account -> {
assertThat(account.customerId()).isEqualTo(9L);
assertThat(account.customerName()).isEqualTo("客户9");
assertThat(account.outstandingReceivable()).isEqualByComparingTo("100.00");
});
assertThat(result.page().total()).isEqualTo(1);
assertThat(result.requestId()).isNotBlank();
assertThat(result.traceId()).isNotBlank();
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
assertThat(jdbc.accountListSql)
.contains("ar.direction='RECEIVABLE'", "cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "FROM fin_settlement_line sl")
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'");
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
}
@Test
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
assertThat(result.data()).isEmpty();
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L);
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "status='CONFIRMED'"));
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
}
@Test
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
.thenReturn(1L);
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(9001L);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(19L);
when(rs.getLong("inbound_id")).thenReturn(29L);
when(rs.getLong("container_id")).thenReturn(39L);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("source_no")).thenReturn("IN-9");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商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("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",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page()).satisfies(meta -> {
assertThat(meta.page()).isEqualTo(2);
assertThat(meta.pageSize()).isEqualTo(50);
assertThat(meta.total()).isEqualTo(1);
});
assertThat(result.data()).singleElement().satisfies(source -> {
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierId()).isEqualTo(79L);
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
assertThat(source.statementGenerated()).isFalse();
});
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
}
@Test
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT id,bill_no"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(81L);
when(rs.getLong("created_by")).thenReturn(71L);
when(rs.getString("bill_no")).thenReturn("REC-81");
when(rs.getString("created_by_name")).thenReturn("platform_admin");
when(rs.getString("remark")).thenReturn("八月账单");
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("5200.00"));
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-08-17 13:00:00"));
return List.of(mapper.mapRow(rs, 0));
});
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
assertThat(result.data()).singleElement().satisfies(entry -> {
assertThat(entry.businessType()).isEqualTo("SETTLEMENT_ISSUED");
assertThat(entry.documentType()).isEqualTo("BILL");
assertThat(entry.documentNo()).isEqualTo("REC-81");
assertThat(entry.occurredAt()).isEqualTo(LocalDateTime.of(2026, 8, 17, 13, 0));
assertThat(entry.operatorName()).isEqualTo("platform_admin");
});
}
@Test
void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
}
@Test
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
FinancePhase1OperationService service = new FinancePhase1OperationService(
feeSourceJdbc("APPROVED", true, 2), objectMapper());
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
assertThat(detail.feeSourceId()).isEqualTo(88L);
assertThat(detail.customerId()).isEqualTo(9L);
assertThat(detail.currency()).isEqualTo("CNY");
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
}
@Test
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1);
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="),
(Object[]) any());
}
@Test
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
when(firstleg.voidSource(eq("internal-token"), eq("void-key"), anyString(), any()))
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService(
jdbc, objectMapper(), firstleg, "internal-token");
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
assertThat(result.sourceStatus()).isEqualTo("AVAILABLE");
var command = org.mockito.ArgumentCaptor.forClass(FirstlegFeeSourceClient.VoidCommand.class);
verify(firstleg).voidSource(eq("internal-token"), eq("void-key"), anyString(), command.capture());
assertThat(command.getValue()).satisfies(value -> {
assertThat(value.sourceType()).isEqualTo("INBOUND_FEE");
assertThat(value.sourceFeeId()).isEqualTo(700L);
assertThat(value.sourceVersion()).isEqualTo(3);
assertThat(value.customerId()).isEqualTo(9L);
assertThat(value.inboundId()).isEqualTo(100L);
assertThat(value.operatorName()).isEqualTo("finance");
assertThat(value.reason()).isEqualTo("测试作废");
});
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET status='VOIDED'"),
(Object[]) any());
}
@Test
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
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);
String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED";
int version = "PENDING".equals(review) ? 0 : 1;
return List.of(mapper.mapRow(resultSet(review, false, version), 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_REVIEW"), eq("review-key"))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
eq("FEE_SOURCE_REVIEW"), eq("review-key"), anyString())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="),
eq("APPROVED"), eq("核对无误"), eq(88L), eq(0))).thenReturn(1);
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"),
eq(88L), eq("FEE_SOURCE_REVIEW_APPROVE"), eq("核对无误"), anyString(), anyString(),
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
assertThat(result.reviewStatus()).isEqualTo("APPROVED");
assertThat(result.operationVersion()).isEqualTo(1);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
}
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 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());
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
return jdbc;
}
private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(700L);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(90L);
when(rs.getLong("inbound_id")).thenReturn(100L);
when(rs.getLong("container_id")).thenReturn(200L);
when(rs.wasNull()).thenReturn(false, false, true, false, false, true);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_no")).thenReturn("IN-100");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("头程运费");
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("status")).thenReturn("AVAILABLE");
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.getInt("operation_version")).thenReturn(version);
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"inboundCode\":\"NYEXX2608180003\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
return rs;
}
private static ObjectMapper objectMapper() {
return new ObjectMapper().findAndRegisterModules();
}
private static final class AccountJdbc extends JdbcTemplate {
private final List<Long> ledgerCustomerIds = new ArrayList<>();
private final List<String> ledgerSql = new ArrayList<>();
private String accountListSql = "";
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.startsWith("SELECT COUNT(*)")) return requiredType.cast(1L);
return null;
}
@Override
@SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.startsWith("SELECT c.customer_id")) {
accountListSql = sql;
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|| sql.startsWith("SELECT id,cost_no") || sql.startsWith("SELECT p.id")) {
ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue());
ledgerSql.add(sql);
return List.of();
}
return List.of();
}
}
}