fix(finance): reconcile payment progress audit amounts and customer names
This commit is contained in:
@@ -7,9 +7,16 @@ import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.finance.service.BusinessFeeSourceEventService;
|
||||
import com.baiyue.finance.service.FinanceSupplierValidator;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
|
||||
import com.baiyue.finance.dto.BusinessFeeSourceVersion;
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@@ -25,6 +32,7 @@ import static org.mockito.Mockito.never;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -37,7 +45,8 @@ class BusinessFeeSourceEventServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class);
|
||||
private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers);
|
||||
private final BusinessFeeSourceRepository sources = mock(BusinessFeeSourceRepository.class);
|
||||
private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers, sources);
|
||||
|
||||
@Test
|
||||
void importsOverseasFeeWithoutInventingInboundReference() {
|
||||
@@ -116,19 +125,93 @@ class BusinessFeeSourceEventServiceTest {
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
/** 旧版本不能覆盖已有较新版本,客户相同时仍保留原乱序门禁。 */
|
||||
@Test
|
||||
void lateOlderVersionCannotSupersedeNewerSourceOrCost() {
|
||||
when(suppliers.requireActive(71L, null)).thenReturn(
|
||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
||||
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"),
|
||||
eq("OVERSEAS_CONTAINER_FEE"), eq(701L)))
|
||||
.thenReturn(List.of(Map.of("source_fee_version", 2)));
|
||||
when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L))
|
||||
.thenReturn(List.of(new BusinessFeeSourceVersion(2, 10001L)));
|
||||
assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1)))
|
||||
.hasMessageContaining("版本已过期");
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
|
||||
}
|
||||
|
||||
/** 已完成的同一事件不再查询会停用、改名或暂时不可用的供应商主档。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FIN_409", "FIN_503", "RENAMED"})
|
||||
void completedReplayDoesNotDependOnCurrentSupplier(String change) {
|
||||
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId())))
|
||||
.thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L)));
|
||||
if ("RENAMED".equals(change)) {
|
||||
when(suppliers.requireActive(71L, null)).thenReturn(
|
||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "新供应商名称", null, "ACTIVE"));
|
||||
} else {
|
||||
when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(change, "供应商不可用"));
|
||||
}
|
||||
assertThat(service.consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE);
|
||||
verify(suppliers, never()).requireActive(anyLong(), any());
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
/** 首次接收仍须失败关闭,不能为修复重放而绕过供应商校验。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FIN_409", "FIN_503"})
|
||||
void newPayableStillRejectsUnavailableSupplier(String code) {
|
||||
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
|
||||
when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(code, "供应商不可用"));
|
||||
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("供应商不可用");
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
/** 两个摘要均正确也不能绕过金额算术、人民币汇率及精度约束。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"USD,1.00,8.000000,120.00", "CNY,120.00,2.000000,240.00",
|
||||
"USD,1.00,8.000000,8.001", "USD,1.001,8.000000,8.01", "USD,1.00,8.0000001,8.00"})
|
||||
void rejectsInconsistentAmountOrPrecision(String currency, String original, String rate, String total) {
|
||||
EventEnvelope event = convertedEvent(currency, original, rate, total);
|
||||
assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class,
|
||||
error -> assertThat(error.code()).isEqualTo("FIN-EVT-019"));
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
/** 分位舍入与数值相等的尾零均遵循生产端规则,不错误拒收合法费用。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"USD,0.04,0.125000,0.01", "USD,16.85,7.122000,120.01", "CNY,120.0000,1.00000000,120.0000"})
|
||||
void acceptsProducerRoundingAndEquivalentTrailingZeros(String currency, String original, String rate, String total) {
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
|
||||
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||
assertThat(service.consume(convertedEvent(currency, original, rate, total)).outcome())
|
||||
.isEqualTo(DeliveryAck.Outcome.APPLIED);
|
||||
}
|
||||
|
||||
/** 调整只允许原客户,错误归属不得替代旧费用或旧成本。 */
|
||||
@Test
|
||||
void crossCustomerRevisionCannotReplaceExistingSource() {
|
||||
var event = payableEvent("ADJUSTED", 2);
|
||||
when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L))
|
||||
.thenReturn(List.of(new BusinessFeeSourceVersion(1, 10002L)));
|
||||
assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class,
|
||||
error -> assertThat(error.code()).isEqualTo("FIN-EVT-018"));
|
||||
noFinancialWrites();
|
||||
}
|
||||
|
||||
/** 创建金额测试快照时同时更新正式来源摘要与事件摘要。 */
|
||||
private EventEnvelope convertedEvent(String currency, String original, String rate, String total) {
|
||||
EventEnvelope base = eventWithoutInbound();
|
||||
ObjectNode payload = base.payload().deepCopy();
|
||||
payload.put("originalCurrency", currency).put("originalAmount", new BigDecimal(original))
|
||||
.put("exchangeRate", new BigDecimal(rate)).put("totalAmount", new BigDecimal(total));
|
||||
payload.putNull("action"); payload.putNull("sourceHash");
|
||||
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
payload.put("action", "PUBLISHED");
|
||||
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
|
||||
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
|
||||
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayReturnsOriginalSourceWithoutDuplicatingCost() {
|
||||
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot;
|
||||
import com.baiyue.finance.mapper.BusinessFeeSourceMapper;
|
||||
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
|
||||
import com.baiyue.finance.service.BusinessFeeSourceEventService;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinanceSupplierValidator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mybatis.spring.SqlSessionFactoryBean;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 隔离数据库执行真实 Mapper 和接收事务,验证失败不留下收件箱或费用半成品。 */
|
||||
class BusinessFeeSourceReceptionPersistenceTest {
|
||||
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class);
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate transaction;
|
||||
private BusinessFeeSourceEventService service;
|
||||
|
||||
/** MyBatis 与既有接收写入共用 Spring 事务连接,不连接共享 TEST 数据库。 */
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:fee-reception-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(data);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE fin_fee_source(id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,
|
||||
customer_id BIGINT,source_no VARCHAR(64),business_type VARCHAR(32),business_id BIGINT,
|
||||
business_no VARCHAR(128),inbound_code VARCHAR(64),warehouse_id BIGINT,direction VARCHAR(16),
|
||||
order_id BIGINT,inbound_id BIGINT,container_id BIGINT,container_booking_id BIGINT,container_no VARCHAR(64),
|
||||
fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),amount DECIMAL(20,6),currency_code VARCHAR(3),
|
||||
settlement_mode VARCHAR(16),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128),
|
||||
payment_voucher_file_ids VARCHAR(2000),source_hash VARCHAR(71),source_snapshot VARCHAR(10000),
|
||||
review_status VARCHAR(16),status VARCHAR(16),wallet_hold_id BIGINT,operation_version INT DEFAULT 0,
|
||||
UNIQUE(source_system,source_type,source_fee_id,source_fee_version))
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64),
|
||||
payload VARCHAR(15000),payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT,
|
||||
processed_at TIMESTAMP,error_code VARCHAR(64),PRIMARY KEY(source_system,event_id))
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE fin_cost(id BIGINT AUTO_INCREMENT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(200),
|
||||
cost_type VARCHAR(32),cost_origin VARCHAR(32),source_system VARCHAR(32),source_type VARCHAR(32),
|
||||
source_fee_id BIGINT,source_fee_version INT,source_hash VARCHAR(71),order_id BIGINT,inbound_id BIGINT,
|
||||
container_id BIGINT,warehouse_id BIGINT,supplier_id BIGINT,supplier_name_snapshot VARCHAR(128),
|
||||
fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),currency_code VARCHAR(3),cost_amount DECIMAL(20,6),
|
||||
voucher_file_ids VARCHAR(2000),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128),
|
||||
status VARCHAR(16),confirmed_at TIMESTAMP,created_by BIGINT,created_by_name VARCHAR(64),updated_by BIGINT,
|
||||
voided_at TIMESTAMP,void_reason VARCHAR(200),version INT DEFAULT 0,
|
||||
UNIQUE(source_system,source_type,source_fee_id,source_fee_version))
|
||||
""");
|
||||
jdbc.execute("CREATE TABLE fin_bill(id BIGINT,tenant_id BIGINT,status VARCHAR(16))");
|
||||
jdbc.execute("CREATE TABLE fin_bill_line(bill_id BIGINT,tenant_id BIGINT,source_id BIGINT,source_version INT)");
|
||||
jdbc.execute("CREATE TABLE fin_settlement(id BIGINT,tenant_id BIGINT,status VARCHAR(16))");
|
||||
jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)");
|
||||
var configuration = new Configuration();
|
||||
configuration.addMapper(BusinessFeeSourceMapper.class);
|
||||
var factory = new SqlSessionFactoryBean();
|
||||
factory.setDataSource(data);
|
||||
factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(BusinessFeeSourceMapper.class);
|
||||
service = new BusinessFeeSourceEventService(jdbc, json, suppliers, new BusinessFeeSourceRepository(mapper));
|
||||
transaction = new TransactionTemplate(new DataSourceTransactionManager(data));
|
||||
when(suppliers.requireActive(71L, null)).thenReturn(new SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
||||
}
|
||||
|
||||
/** 释放每个用例独占的内存数据库。 */
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (jdbc != null) jdbc.execute("SHUTDOWN");
|
||||
}
|
||||
|
||||
/** 已提交后模拟丢失回执,供应商停用或故障都不产生第二笔费用和成本。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FIN_409", "FIN_503"})
|
||||
void committedDirectPaidReplayKeepsExactlyOneSourceAndCost(String errorCode) {
|
||||
var event = event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00");
|
||||
assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED);
|
||||
reset(suppliers);
|
||||
when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(errorCode, "供应商不可用"));
|
||||
|
||||
assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE);
|
||||
verifyNoInteractions(suppliers);
|
||||
assertCounts(1, 1, 1);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_inbox_event", String.class)).isEqualTo("PROCESSED");
|
||||
assertThat(jdbc.queryForObject("SELECT cost_amount FROM fin_cost", BigDecimal.class)).isEqualByComparingTo("12.00");
|
||||
}
|
||||
|
||||
/** 不同客户的升版失败时回滚已插入 Inbox,原费用与原直付成本保持有效。 */
|
||||
@Test
|
||||
void foreignCustomerRevisionRollsBackAndSameCustomerCanStillAdjust() {
|
||||
consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00"));
|
||||
var wrong = event(701, 2, 10002, "DIRECT_PAID", "CNY", "18.00", "1", "18.00");
|
||||
assertThatThrownBy(() -> consume(wrong)).isInstanceOfSatisfying(FinanceEventException.class,
|
||||
error -> assertThat(error.code()).isEqualTo("FIN-EVT-018"));
|
||||
assertCounts(1, 1, 1);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_cost", String.class)).isEqualTo("CONFIRMED");
|
||||
assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source", Long.class)).isEqualTo(10001L);
|
||||
|
||||
consume(event(701, 2, 10001, "DIRECT_PAID", "CNY", "18.00", "1", "18.00"));
|
||||
assertCounts(2, 2, 2);
|
||||
assertThat(jdbc.queryForList("SELECT status FROM fin_fee_source ORDER BY source_fee_version", String.class))
|
||||
.containsExactly("SUPERSEDED", "AVAILABLE");
|
||||
assertThat(jdbc.queryForList("SELECT status FROM fin_cost ORDER BY source_fee_version", String.class))
|
||||
.containsExactly("VOIDED", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 错误换算拒收后不残留收件记录,已存在版本也不会被替代。 */
|
||||
@Test
|
||||
void invalidConversionLeavesOriginalAmountsAndReceiptIntact() {
|
||||
consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00"));
|
||||
assertThatThrownBy(() -> consume(event(701, 2, 10001, "ON_ACCOUNT", "USD", "1.00", "8", "120.00")))
|
||||
.isInstanceOfSatisfying(FinanceEventException.class,
|
||||
error -> assertThat(error.code()).isEqualTo("FIN-EVT-019"));
|
||||
assertCounts(1, 1, 0);
|
||||
assertThat(jdbc.queryForObject("SELECT amount FROM fin_fee_source", BigDecimal.class)).isEqualByComparingTo("12.00");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE");
|
||||
}
|
||||
|
||||
/** 只锁定相同系统、来源类型和费用号,其他客户的独立费用不会阻塞正常升版。 */
|
||||
@Test
|
||||
void independentSourcesKeepSeparateCustomerIdentity() {
|
||||
consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00"));
|
||||
consume(event(702, 1, 10002, "ON_ACCOUNT", "CNY", "9.00", "1", "9.00"));
|
||||
consume(event(701, 2, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00"));
|
||||
assertCounts(3, 3, 0);
|
||||
assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source WHERE source_fee_id=702", Long.class))
|
||||
.isEqualTo(10002L);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source WHERE source_fee_id=702", String.class))
|
||||
.isEqualTo("AVAILABLE");
|
||||
}
|
||||
|
||||
/** 提前恢复幂等结果不放宽事件冲突校验,异载荷仍须拒绝。 */
|
||||
@Test
|
||||
void changedPayloadCannotUseProcessedEventIdentity() {
|
||||
var first = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00");
|
||||
consume(first);
|
||||
var changed = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00");
|
||||
var conflict = new EventEnvelope(first.eventId(), changed.eventType(), changed.eventVersion(), changed.occurredAt(),
|
||||
changed.tenantId(), changed.aggregateType(), changed.aggregateId(), changed.aggregateVersion(), null,
|
||||
changed.traceId(), changed.correlationId(), null, changed.payload(), changed.payloadHash());
|
||||
reset(suppliers);
|
||||
assertThatThrownBy(() -> consume(conflict)).isInstanceOfSatisfying(FinanceEventException.class,
|
||||
error -> assertThat(error.code()).isEqualTo("FIN-EVT-002"));
|
||||
verifyNoInteractions(suppliers);
|
||||
assertCounts(1, 1, 0);
|
||||
}
|
||||
|
||||
/** 即使失败发生在费用插入之后,收件箱、费用及成本也必须一起回滚。 */
|
||||
@Test
|
||||
void costWriteFailureRollsBackReceiptAndSource() {
|
||||
jdbc.execute("ALTER TABLE fin_cost ADD CONSTRAINT test_cost_limit CHECK(cost_amount <= 10)");
|
||||
assertThatThrownBy(() -> consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00")))
|
||||
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
assertCounts(0, 0, 0);
|
||||
}
|
||||
|
||||
/** 以正式服务事务边界执行,Mapper 与遗留 JDBC 写入必须共进退。 */
|
||||
private DeliveryAck consume(EventEnvelope event) {
|
||||
return transaction.execute(status -> service.consume(event));
|
||||
}
|
||||
|
||||
/** 校验整笔接收事实数量,防止幂等通过但产生额外财务明细。 */
|
||||
private void assertCounts(int inbox, int sources, int costs) {
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Integer.class)).isEqualTo(inbox);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Integer.class)).isEqualTo(sources);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_cost", Integer.class)).isEqualTo(costs);
|
||||
}
|
||||
|
||||
/** 构造经过两个正式摘要计算的费用快照,金额错误用例不会被摘要错误提前拦截。 */
|
||||
private EventEnvelope event(long feeId, int version, long customer, String mode, String currency,
|
||||
String original, String rate, String total) {
|
||||
ObjectNode payload = json.createObjectNode();
|
||||
payload.putNull("action");
|
||||
payload.put("sourceSystem", "LASTLEG").put("sourceType", "OVERSEAS_WAREHOUSE_FEE")
|
||||
.put("sourceFeeId", String.valueOf(feeId)).put("sourceVersion", version)
|
||||
.put("customerId", String.valueOf(customer)).put("customerName", "验收客户")
|
||||
.put("sourceNo", "ZY-RECEPTION").put("businessType", "WAREHOUSE_SERVICE")
|
||||
.put("businessId", "801").put("businessNo", "ZY-RECEPTION");
|
||||
for (String field : new String[]{"orderId", "inboundId", "inboundCode", "containerId", "containerBookingId", "containerNo"})
|
||||
payload.putNull(field);
|
||||
payload.put("warehouseId", "91").put("feeItemCode", "FEE_TYPE_003").put("feeItemName", "人工费")
|
||||
.put("direction", "PAYABLE").put("supplierId", "71").put("supplierName", "供应商");
|
||||
payload.putNull("supplierCategory"); payload.put("settlementMode", mode);
|
||||
if ("DIRECT_PAID".equals(mode)) {
|
||||
payload.set("paidAt", json.valueToTree(Instant.parse("2026-10-27T01:00:00Z")));
|
||||
payload.put("paymentMethod", "BANK_TRANSFER").put("paymentReferenceNo", "TEST-RECEPTION");
|
||||
} else {
|
||||
payload.putNull("paidAt"); payload.putNull("paymentMethod"); payload.putNull("paymentReferenceNo");
|
||||
}
|
||||
payload.putArray("paymentVoucherFileIds");
|
||||
payload.put("businessDate", "2026-10-27").put("currency", "CNY").put("totalAmount", new BigDecimal(total))
|
||||
.put("originalCurrency", currency).put("originalAmount", new BigDecimal(original))
|
||||
.put("exchangeRate", new BigDecimal(rate)).put("exchangeRateMonth", "2026-10");
|
||||
for (String field : new String[]{"chargeableQuantity", "quantityUnit", "unitPrice", "periodStart", "periodEnd"})
|
||||
payload.putNull(field);
|
||||
payload.put("reviewStatus", "APPROVED").put("publicationStatus", "PUBLISHED").put("sourceStatus", "ACTIVE");
|
||||
payload.putNull("sourceHash"); payload.putNull("walletSettlement");
|
||||
// 使用正式 DTO 规范化日期等类型,和生产者的序列化输入保持一致。
|
||||
payload = json.valueToTree(json.convertValue(payload, BusinessFeeSourceChangedV1.class));
|
||||
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
payload.put("action", version == 1 ? "PUBLISHED" : "ADJUSTED");
|
||||
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1,
|
||||
Instant.parse("2026-10-27T01:00:00Z"), String.valueOf(customer), "BUSINESS_FEE", String.valueOf(feeId), version,
|
||||
null, UUID.randomUUID().toString(), "TEST:FEE:" + feeId + ':' + version, null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L);
|
||||
var result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccount(9L);
|
||||
|
||||
assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER");
|
||||
var amountSql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
@@ -60,7 +60,7 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00");
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
CustomerAccountSummary result = service.customerAccountSummary(9L);
|
||||
|
||||
@@ -99,7 +99,7 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(77L);
|
||||
|
||||
assertThat(result.totalReceivable()).isZero();
|
||||
assertThat(result.receivedAmount()).isZero();
|
||||
@@ -116,7 +116,7 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
|
||||
|
||||
assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L))
|
||||
assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(88L))
|
||||
.isInstanceOf(BadSqlGrammarException.class);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.finance.client.IamCustomerClient;
|
||||
import com.baiyue.finance.service.FinanceCustomerNames;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FinanceCustomerNamesTest {
|
||||
private final IamCustomerClient iam = mock(IamCustomerClient.class);
|
||||
private final FinanceCustomerNames names = new FinanceCustomerNames(iam, "internal-token");
|
||||
|
||||
@Test
|
||||
void resolvesInternalLabelsAndMissingNamesOncePerCustomerWithoutReplacingValidSnapshots() {
|
||||
respond(9, "当前公司名称");
|
||||
var resolver = names.resolver();
|
||||
assertThat(resolver.apply(9L, "客户9")).isEqualTo("当前公司名称");
|
||||
assertThat(resolver.apply(9L, "9")).isEqualTo("当前公司名称");
|
||||
assertThat(resolver.apply(9L, null)).isEqualTo("当前公司名称");
|
||||
assertThat(resolver.apply(9L, "原单公司名称")).isEqualTo("原单公司名称");
|
||||
verify(iam, times(1)).page("internal-token", 1, 1, null, null, 9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingCustomerDisplaysDashButCannotCreateNewDocument() {
|
||||
when(iam.page("internal-token", 1, 1, null, null, 9L))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 1, 0, 0)));
|
||||
assertThat(names.resolver().apply(9L, "客户9")).isEqualTo("—");
|
||||
assertThatThrownBy(() -> names.requiredSnapshot(9, "客户9")).isInstanceOf(FinanceEventException.class).hasMessageContaining("无法生成");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMismatchedCustomerInsteadOfLeakingAnotherCompanyName() {
|
||||
respond(10, "其他公司");
|
||||
assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("范围异常");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyFailureCannotFallBackToInternalIdOrSilentlyCreateDocument() {
|
||||
when(iam.page("internal-token", 1, 1, null, null, 9L)).thenThrow(new IllegalStateException("offline"));
|
||||
assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("暂不可用");
|
||||
assertThat(names.resolver().apply(9L, "已记录公司名称")).isEqualTo("已记录公司名称");
|
||||
}
|
||||
|
||||
private void respond(long returnedId, String name) {
|
||||
when(iam.page("internal-token", 1, 1, null, null, 9L)).thenReturn(ApiResponse.success(
|
||||
new PageResult<>(List.of(new IamCustomerClient.Customer(returnedId, "TEST", name, "ACTIVE", null, null)), 1, 1, 1, 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.finance.client.IamCustomerClient;
|
||||
import com.baiyue.finance.service.FinanceCustomerNames;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/** 财务服务单测使用真实名称解析器及隔离的 IAM 主档,不请求测试环境。 */
|
||||
public final class FinanceCustomerNamesTestSupport {
|
||||
private FinanceCustomerNamesTestSupport() {}
|
||||
|
||||
public static FinanceCustomerNames names() {
|
||||
var iam = mock(IamCustomerClient.class, invocation -> {
|
||||
if (!invocation.getMethod().getName().equals("page")) return org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation);
|
||||
long id = invocation.getArgument(5);
|
||||
return ApiResponse.success(new PageResult<>(List.of(new IamCustomerClient.Customer(id, "TEST", "测试客户公司", "ACTIVE", null, null)), 1, 1, 1, 1));
|
||||
});
|
||||
return new FinanceCustomerNames(iam, "internal-token");
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class FinanceDirectPaidCostReadTest {
|
||||
jdbc.execute("CREATE TABLE fin_cost_allocation(id BIGINT,tenant_id BIGINT,cost_id BIGINT,order_id BIGINT,"
|
||||
+ "inbound_id BIGINT,volume_cbm DECIMAL(18,6),total_volume_cbm DECIMAL(18,6),allocation_rate DECIMAL(18,6),"
|
||||
+ "allocated_amount DECIMAL(18,2),rounding_adjustment BOOLEAN)");
|
||||
service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
}
|
||||
|
||||
/** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */
|
||||
|
||||
@@ -18,7 +18,7 @@ class FinanceFeePoolDirectionBehaviorTest {
|
||||
@Test
|
||||
void receivablePoolNeverContainsPayableSource() {
|
||||
DirectionJdbc jdbc = new DirectionJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
List<FeePoolItem> result = service.feePool(7L, "RECEIVABLE");
|
||||
|
||||
@@ -30,7 +30,7 @@ class FinanceFeePoolDirectionBehaviorTest {
|
||||
@Test
|
||||
void payablePoolNeverContainsReceivableSource() {
|
||||
DirectionJdbc jdbc = new DirectionJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
List<FeePoolItem> result = service.feePool(7L, "PAYABLE");
|
||||
|
||||
@@ -42,7 +42,7 @@ class FinanceFeePoolDirectionBehaviorTest {
|
||||
@Test
|
||||
void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() {
|
||||
DirectionJdbc jdbc = new DirectionJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
service.feePool(7L, "PAYABLE", 81L);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -30,8 +31,11 @@ public class FinanceFeeSourceReadMapperTest {
|
||||
try (var connection = data.getConnection()) {
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'");
|
||||
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)");
|
||||
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP,settlement_mode VARCHAR(32),paid_at TIMESTAMP,operation_version INT DEFAULT 0,review_reason VARCHAR(128),source_hash VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT,tenant_id BIGINT,bill_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(24))");
|
||||
jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,direction VARCHAR(16),status VARCHAR(24),payment_status VARCHAR(24))");
|
||||
jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)");
|
||||
insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
|
||||
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00");
|
||||
@@ -103,10 +107,44 @@ public class FinanceFeeSourceReadMapperTest {
|
||||
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
|
||||
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
|
||||
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
|
||||
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
|
||||
assertThat(repository.count(filters)).isEqualTo(1);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
|
||||
if ("RECEIVABLE".equals(direction)) {
|
||||
jdbc.update("INSERT INTO fin_bill VALUES(91,9,'ISSUED')");
|
||||
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2,9,91)");
|
||||
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
|
||||
session.clearCache();
|
||||
assertThat(repository.count(filters)).isEqualTo(1);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
|
||||
assertThat(repository.detail(2).sourceStatus()).isEqualTo("BILLED");
|
||||
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=91");
|
||||
session.clearCache();
|
||||
assertThat(repository.detail(2).statementGenerated()).isFalse();
|
||||
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
|
||||
} else {
|
||||
jdbc.update("INSERT INTO fin_settlement VALUES(91,9,'PAYABLE','ISSUED','UNPAID')");
|
||||
jdbc.update("INSERT INTO fin_settlement_line VALUES(91,9,2)");
|
||||
for (String payment : List.of("UNPAID", "PARTIALLY_PAID", "PAID")) {
|
||||
jdbc.update("UPDATE fin_settlement SET payment_status=? WHERE id=91", payment);
|
||||
String expected = "UNPAID".equals(payment) ? "PAYABLE_BILLED" : payment;
|
||||
filters.put("status", expected);
|
||||
assertThat(repository.count(filters)).isEqualTo(1);
|
||||
assertThat(repository.list(filters)).singleElement().satisfies(row -> {
|
||||
assertThat(row.feeSourceId()).isEqualTo(2);
|
||||
assertThat(row.sourceStatus()).isEqualTo(expected);
|
||||
assertThat(row.statementGenerated()).isFalse();
|
||||
assertThat(row.amount()).isEqualByComparingTo("120");
|
||||
});
|
||||
assertThat(repository.detail(2).sourceStatus()).isEqualTo(expected);
|
||||
}
|
||||
jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=91");
|
||||
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
|
||||
jdbc.update("UPDATE fin_settlement SET status='PAID',tenant_id=10 WHERE id=91");
|
||||
jdbc.update("UPDATE fin_settlement_line SET tenant_id=10 WHERE settlement_id=91");
|
||||
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
|
||||
jdbc.update("UPDATE fin_fee_source SET settlement_mode='DIRECT_PAID',paid_at='2026-10-21 10:00:00' WHERE id=2");
|
||||
assertThat(repository.detail(2).sourceStatus()).isEqualTo("PAID");
|
||||
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=2");
|
||||
assertThat(repository.detail(2).sourceStatus()).isEqualTo("VOIDED");
|
||||
}
|
||||
|
||||
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
|
||||
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
|
||||
|
||||
@@ -189,8 +189,8 @@ class FinancePhase1OperationBehaviorTest {
|
||||
/** 保留费用审核状态读取。 */
|
||||
@Test
|
||||
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 2));
|
||||
|
||||
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
||||
}
|
||||
@@ -199,7 +199,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
@Test
|
||||
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
mock(JdbcTemplate.class), objectMapper(), null, "", feeSources("APPROVED", true, 2));
|
||||
|
||||
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
||||
|
||||
@@ -212,8 +212,8 @@ class FinancePhase1OperationBehaviorTest {
|
||||
/** 业务来源费用必须回到所属系统调整。 */
|
||||
@Test
|
||||
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 1));
|
||||
|
||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
||||
new BigDecimal("120.00"), "更正金额", 1);
|
||||
@@ -228,7 +228,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
||||
@Test
|
||||
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
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);
|
||||
@@ -236,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
||||
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
|
||||
jdbc, objectMapper(), firstleg, "internal-token", feeSources("APPROVED", false, 2));
|
||||
|
||||
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
||||
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
||||
@@ -262,13 +262,9 @@ class FinancePhase1OperationBehaviorTest {
|
||||
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);
|
||||
var sources = mock(FinanceFeeSourceReadRepository.class);
|
||||
when(sources.detail(88L)).thenAnswer(invocation -> reads.getAndIncrement() == 0
|
||||
? feeDetail("PENDING", false, 0) : feeDetail("APPROVED", false, 1));
|
||||
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),
|
||||
@@ -280,7 +276,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
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(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", sources);
|
||||
|
||||
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||
@@ -332,45 +328,30 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.when(service).getFinanceFeeSource(88L);
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
|
||||
for (String occupied : List.of("PAYABLE_BILLED", "PARTIALLY_PAID", "PAID")) {
|
||||
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("sourceStatus", occupied), FeeSourceDetail.class))
|
||||
.when(service).getFinanceFeeSource(88L);
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "occupied-" + occupied, request, true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("费用已被占用、结清或作废");
|
||||
}
|
||||
}
|
||||
|
||||
/** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */
|
||||
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);
|
||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
||||
return jdbc;
|
||||
/** 服务行为测试由 Repository 返回明确快照;正式 SQL 与 DTO 映射由 Mapper 数据库测试覆盖。 */
|
||||
private static FinanceFeeSourceReadRepository feeSources(String review, boolean generated, int version) throws Exception {
|
||||
var repository = mock(FinanceFeeSourceReadRepository.class);
|
||||
when(repository.detail(88L)).thenReturn(feeDetail(review, generated, version));
|
||||
return repository;
|
||||
}
|
||||
|
||||
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 FeeSourceDetail feeDetail(String review, boolean generated, int version) throws Exception {
|
||||
var node = objectMapper().createObjectNode().put("feeSourceId",88).put("sourceSystem","FIRSTLEG")
|
||||
.put("sourceType","INBOUND_FEE").put("sourceFeeId",700).put("sourceVersion",3).put("customerId",9)
|
||||
.put("direction","RECEIVABLE").put("orderId",90).put("inboundId",100).put("containerId",200)
|
||||
.put("inboundCode","NYEXX2608180003").put("sourceBusinessId","IN-100").put("feeItemName","头程运费")
|
||||
.put("amount",100).put("currency","CNY").put("operationVersion",version).put("reviewStatus",review)
|
||||
.put("sourceStatus",generated ? "BILLED" : "AVAILABLE").put("statementGenerated",generated)
|
||||
.put("sourceHash","a".repeat(64)).put("businessDate","2026-08-11");
|
||||
return objectMapper().treeToValue(node,FeeSourceDetail.class);
|
||||
}
|
||||
|
||||
private static ObjectMapper objectMapper() {
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
||||
@Test
|
||||
void allCustomerQueriesOmitCustomerPredicateAndArguments() {
|
||||
RecordingJdbc jdbc = new RecordingJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
service.feePool(null, null);
|
||||
service.statements(null, false);
|
||||
@@ -33,7 +33,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
||||
@Test
|
||||
void customerFilterAddsPredicateAndRealCustomerArgument() {
|
||||
RecordingJdbc jdbc = new RecordingJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
service.feePool(31L, null);
|
||||
service.statements(31L, false);
|
||||
@@ -49,7 +49,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
||||
|
||||
@Test
|
||||
void optionalCustomerNeverTreatsZeroAsAllCustomers() {
|
||||
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper());
|
||||
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper(), FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效");
|
||||
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
|
||||
|
||||
@@ -100,6 +100,7 @@ class FinanceSettlementBehaviorTest {
|
||||
|
||||
assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L);
|
||||
assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L);
|
||||
assertThat(scoped).singleElement().extracting(SettlementSummary::customerName).isEqualTo("测试客户公司");
|
||||
ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
|
||||
@@ -633,7 +634,7 @@ class FinanceSettlementBehaviorTest {
|
||||
return jdbc.queryForList(sql, invocation.getArguments());
|
||||
});
|
||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names());
|
||||
}
|
||||
|
||||
private static FinanceSupplierValidator supplierValidator() {
|
||||
|
||||
@@ -28,7 +28,7 @@ class ReceivableStatisticsServiceTest {
|
||||
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);
|
||||
ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names());
|
||||
|
||||
var result = service.query(YearMonth.of(2026, 7), null, 1, 1);
|
||||
|
||||
@@ -51,12 +51,35 @@ class ReceivableStatisticsServiceTest {
|
||||
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)
|
||||
assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names())
|
||||
.query(YearMonth.of(2026, 7), null, 1, 20))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.hasMessageContaining("勾稽异常");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void queryAndExportResolveTheSameCustomerNameWithoutChangingAmounts() throws Exception {
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
|
||||
RowMapper<ReceivableStatisticsRow> mapper = invocation.getArgument(1);
|
||||
var rs = mock(java.sql.ResultSet.class);
|
||||
when(rs.getLong("tenant_id")).thenReturn(9L);
|
||||
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
||||
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal("300.00"));
|
||||
when(rs.getBigDecimal("total_received")).thenReturn(new BigDecimal("100.00"));
|
||||
when(rs.getBigDecimal("total_outstanding")).thenReturn(new BigDecimal("200.00"));
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
var service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names());
|
||||
var query = service.query(YearMonth.of(2026, 10), 9L, 1, 20);
|
||||
assertThat(query.records()).singleElement().satisfies(row -> {
|
||||
assertThat(row.customerName()).isEqualTo("测试客户公司");
|
||||
assertThat(row.totalReceivable()).isEqualByComparingTo("300.00");
|
||||
});
|
||||
assertThat(service.exportRows(YearMonth.of(2026, 10), 9L)).isEqualTo(query.records());
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -83,6 +83,8 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
|
||||
JdbcTemplate jdbc = createJdbc();
|
||||
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L)))
|
||||
.thenReturn(List.of());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
|
||||
@@ -90,7 +92,15 @@ class ReconciliationStatementBehaviorTest {
|
||||
var result = target.create(71L, "cutoff-key", request);
|
||||
|
||||
assertThat(result.status()).isEqualTo("DRAFT");
|
||||
assertThat(result.customerName()).isEqualTo("测试客户公司");
|
||||
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00");
|
||||
var creator = ArgumentCaptor.forClass(PreparedStatementCreator.class);
|
||||
verify(jdbc).update(creator.capture(), any(KeyHolder.class));
|
||||
var connection = mock(java.sql.Connection.class);
|
||||
var insert = mock(java.sql.PreparedStatement.class);
|
||||
when(connection.prepareStatement(anyString(), eq(java.sql.Statement.RETURN_GENERATED_KEYS))).thenReturn(insert);
|
||||
creator.getValue().createPreparedStatement(connection);
|
||||
verify(insert).setString(4, "测试客户公司");
|
||||
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)));
|
||||
}
|
||||
@@ -589,9 +599,30 @@ class ReconciliationStatementBehaviorTest {
|
||||
};
|
||||
}
|
||||
|
||||
/** 审批前后使用不同 DTO,实际序列化后的详情金额必须完整返回而非变成零。 */
|
||||
@Test
|
||||
void revisionReadsBeforeBillAndAfterDetailAmountsAndRejectsMissingAmount() throws Exception {
|
||||
var service = serviceWithNumber(mock(JdbcTemplate.class));
|
||||
var rs = mock(ResultSet.class);
|
||||
when(rs.getString("action")).thenReturn("RECONCILIATION_ADJUST");
|
||||
when(rs.getString("before_data")).thenReturn("{\"total\":240.00}");
|
||||
when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":240.03}");
|
||||
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-10-27 10:00:00"));
|
||||
com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision revision =
|
||||
ReflectionTestUtils.invokeMethod(service, "revision", rs, 1);
|
||||
assertThat(revision.beforeAmount()).isEqualByComparingTo("240.00");
|
||||
assertThat(revision.afterAmount()).isEqualByComparingTo("240.03");
|
||||
when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":9999999999999999.99}");
|
||||
revision = ReflectionTestUtils.invokeMethod(service, "revision", rs, 1);
|
||||
assertThat(revision.afterAmount()).isEqualByComparingTo("9999999999999999.99");
|
||||
when(rs.getString("after_data")).thenReturn("{}");
|
||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "revision", rs, 1))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("快照损坏");
|
||||
}
|
||||
|
||||
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
||||
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001");
|
||||
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
||||
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers, FinanceCustomerNamesTestSupport.names());
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -85,6 +85,26 @@ class FinanceExportProviderServiceTest {
|
||||
assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsPaidSourcesUsingTheSameProjectionAndFiltersAsTheList() throws Exception {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var source = mapper.treeToValue(mapper.createObjectNode().put("feeSourceId", 81).put("sourceFeeId", 9001)
|
||||
.put("sourceVersion", 2).put("sourceSystem", "FIRSTLEG").put("sourceType", "CONTAINER_FEE")
|
||||
.put("direction", "PAYABLE").put("customerId", 10001).put("customerName", "客户A")
|
||||
.put("feeItemName", "装车费").put("amount", 300).put("currency", "CNY")
|
||||
.put("reviewStatus", "APPROVED").put("sourceStatus", "PAID").put("statementGenerated", false),
|
||||
com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary.class);
|
||||
when(operationService.listFinanceFeeSources(1, 200, null, "PAYABLE", "PAID", null, null, null, null, null))
|
||||
.thenReturn(PageEnvelope.success(List.of(source), 1, 200, 1));
|
||||
var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST,
|
||||
request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null));
|
||||
assertThat(result.rows()).singleElement().satisfies(row -> {
|
||||
assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001");
|
||||
assertThat(row.get("amount")).isEqualTo(new BigDecimal("300"));
|
||||
});
|
||||
org.mockito.Mockito.verifyNoInteractions(queryService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() {
|
||||
assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
|
||||
|
||||
Reference in New Issue
Block a user