merge: preserve TEST finance and rent changes with fleet photo updates

This commit is contained in:
2026-10-26 17:21:43 +08:00
181 changed files with 9007 additions and 396 deletions
@@ -48,22 +48,22 @@ class DispatchDeliveryFeesTest {
}
/** 无引用时不强制Finance可用,已有外接约定组禁止新增派送应收。 */
@Test void ordinaryNeedsPositiveFeeAndExternalQuoteDoesNotWaiveOtherCargo() {
@Test void ordinaryAllowsLaterManualFeeAndOldReceivableInputIsRejected() {
var row=cargo(1,"101","ONT8",51,2);var group=grouping.groups(context(List.of(row),false)).get(0);
var input=input(group,List.of(),List.of());
assertNull(service.coverage(group,input,"OP",ordinary()));verifyNoInteractions(finance);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,null,ordinary()));
service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary());
service.validate(group,input,null,ordinary());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary()));
var quote=new FleetBookingFreight.Quote("BOOK-1",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO);
var allExternal=new FleetBookingFreight.Preview("freight",false,List.of(quote),List.of());
service.validate(group,input,null,allExternal);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,allExternal));
var mixed=grouping.groups(context(List.of(row,cargo(2,"101","ONT8",51,3)),false)).get(0);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal));
service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal);
}
/** 正数报价发布到自己的客户目的地,零约定仅保存商业操作不造零费用。 */
@Test void zeroAgreementRecordsFactAndPositiveFeeUsesOwnDelivery() {
@Test void zeroAndPositiveQuotesKeepFactsWithoutGeneratingReceivables() {
var row=cargo(1,"101","ONT8",51,2);var context=context(List.of(row),false);var group=grouping.groups(context).get(0);
var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001");
var zero=new FleetBookingFreight.Quote("BOOK-ZERO",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO);
@@ -71,9 +71,7 @@ class DispatchDeliveryFeesTest {
verify(repository).operation(any());verifyNoInteractions(sources);
var positive=new FleetBookingFreight.Quote("BOOK-POSITIVE",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.TEN,BigDecimal.ONE,"单",BigDecimal.TEN);
service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(positive),List.of()),"OP","ARR-1");
var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);verify(sources).publishApproved(eq("OP"),anyString(),saved.capture());
assertEquals("101",saved.getValue().customerId());assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo());
assertEquals(BigDecimal.TEN,saved.getValue().amount());
verify(repository,times(2)).operation(any()); verifyNoInteractions(sources);
}
/** 合托两票一笔原费只能覆盖对应来源,不得免掉另一票新增应收。 */
@@ -88,7 +86,7 @@ class DispatchDeliveryFeesTest {
var evidence=service.coverage(group,input,"OP",ordinary());
assertEquals(2,evidence.cargo().size());
verify(finance).sources("offline-test-token",new FinanceFeeCoverageClient.Query(101L,List.of(501L,502L)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,evidence,ordinary()));
service.validate(group,input,evidence,ordinary());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group,
input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary()));
}
@@ -102,7 +100,7 @@ class DispatchDeliveryFeesTest {
var group=grouping.groups(context).get(0);
var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");
delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001");
service.publish(context,group,input(group,List.of(),List.of(fee("RECEIVABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL");
service.publish(context,group,input(group,List.of(),List.of(fee("PAYABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL");
var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);
verify(sources).publishApproved(eq("OP"),anyString(),saved.capture());
assertEquals("DELIVERY_ORDER",saved.getValue().businessType());
@@ -133,6 +133,7 @@ class DeliveryTransferServiceTest {
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var quantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
ReflectionTestUtils.setField(quantities, "pallets", outboundPallets);
ReflectionTestUtils.setField(service, "outboundQuantities", quantities);
@@ -451,22 +452,6 @@ class DeliveryTransferServiceTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"})
@Test
void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301");
@@ -37,7 +37,8 @@ class RelabelOutboundPickingTest {
/** 构造返工后新目标位:两箱冻结,账面混合SKU,与空出库SKU不作通配匹配。 */
@BeforeEach void prepare() {
service = new DeliveryTransferService(); commands = mock(InventoryCommandService.class);
service = new DeliveryTransferService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); commands = mock(InventoryCommandService.class);
picks=mock(OutboundPdaPickMapper.class); tasks=mock(OutboundPdaTaskMapper.class);
orders=mock(OutboundOrderMapper.class); pallets=mock(OutboundPalletItemMapper.class);
positions=mock(InventoryPositionMapper.class); balances=mock(InventoryBalanceMapper.class);
@@ -57,6 +57,7 @@ class ReturnOutboundClosureTest {
ReturnInventoryCarton carton;
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "storageRent", mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
// 取消入口先查询幂等记录,手动组装的测试服务也需要注入该仓储。
ReflectionTestUtils.setField(service, "cancellations", new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
outbounds, items, deliveries, tasks, mock(DeliveryOperationMapper.class), relabelApplications, cartons,
@@ -263,6 +264,7 @@ class ReturnOutboundClosureTest {
var relabelMapper = traced(sessions.getMapper(RelabelApplicationMapper.class), RelabelApplicationMapper.class, "lockByOutbound", "relabel");
var cartonMapper = traced(sessions.getMapper(ReturnInventoryCartonMapper.class), ReturnInventoryCartonMapper.class, "lockMark", "carton");
var target = new DeliveryTransferService();
org.springframework.test.util.ReflectionTestUtils.setField(target, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(target,"cancellations",new DeliveryCancellationRepository(outboundMapper,sessions.getMapper(OutboundPalletItemMapper.class),
sessions.getMapper(DeliveryOrderMapper.class),sessions.getMapper(OutboundPdaTaskMapper.class),sessions.getMapper(DeliveryOperationMapper.class),relabelMapper,
cartonMapper,sessions.getMapper(WarehouseTransferOrderMapper.class),sessions.getMapper(LastlegOrderMapper.class),sessions.getMapper(LastlegOrderStatusHistoryMapper.class)));
@@ -65,6 +65,7 @@ class ReturnOutboundPdaPersistenceTest {
movementMapper = session.getMapper(ReturnInventoryMovementMapper.class);
balanceMapper = session.getMapper(ReturnInventoryBalanceMapper.class);
var target = new DeliveryTransferService();
org.springframework.test.util.ReflectionTestUtils.setField(target, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(target, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(target, "returnCartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
ReflectionTestUtils.setField(target, "deliveryOrders", session.getMapper(DeliveryOrderMapper.class));
@@ -74,6 +75,7 @@ class ReturnOutboundPdaPersistenceTest {
ReflectionTestUtils.setField(target, "pdaPicks", session.getMapper(OutboundPdaPickMapper.class));
ReflectionTestUtils.setField(target, "returnCartons", session.getMapper(ReturnInventoryCartonMapper.class));
var stock = new ReturnInventoryService();
org.springframework.test.util.ReflectionTestUtils.setField(stock, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(stock, "cartons", session.getMapper(ReturnInventoryCartonMapper.class));
ReflectionTestUtils.setField(stock, "cartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
ReflectionTestUtils.setField(stock, "balances", session.getMapper(ReturnInventoryBalanceMapper.class));
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
@@ -119,6 +119,7 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository();
ReflectionTestUtils.setField(quantities, "items", items);
ReflectionTestUtils.setField(service, "quantities", quantities);
@@ -633,6 +633,7 @@ public class DispatchAggregatePersistenceTest {
service=new DispatchAggregateService(repository,
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
@@ -23,6 +23,20 @@ import static org.mockito.Mockito.*;
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
class DispatchPickingPersistenceTest {
/** 仓租冻结未确认时阻断真实扫码,任务、库存及幂等记录全部回滚。 */
@Test void storageRentFundingGateBlocksActualInventoryAndRollsBackTask() throws Exception {
var fixture=new Picking();
var rent=mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class);
doThrow(new com.baiyue.common.core.exception.BizException("仓租资金尚未冻结")).when(rent).consume(eq("9"),any());
org.springframework.test.util.ReflectionTestUtils.setField(fixture.service,"storageRent",rent);
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"rent-funding-blocked",11L,3,0,null,null)))).hasMessageContaining("尚未冻结");
verify(rent).consume(eq("9"),argThat(request->"rent-funding-blocked".equals(request.idempotencyKey())&&request.packages()==3L));
verify(fixture.inventory,never()).ship(any(),any(),any());
assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isEqualTo(3);
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isZero();
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='rent-funding-blocked'",Long.class)).isZero();
}
/** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */
@Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
@@ -119,6 +133,7 @@ class DispatchPickingPersistenceTest {
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
}
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
@@ -17,6 +17,7 @@ class DispatchRegionalCandidatesTest {
@Test void serviceAcceptsRegionalQueriesAndRejectsMalformedScopes() {
var sources=mock(DispatchService.class);var appointments=mock(DispatchAppointmentAllocations.class);
var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper());
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service,"appointments",appointments);
var actor=new Actor("7",ActorMode.PLATFORM,null);
when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41));
@@ -131,7 +131,8 @@ class DispatchRepairWorkflowTest {
var execution=new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class));dependencies.put(DeliveryExecutionRepository.class,execution);dependencies.put(DeliveryExecutionGuard.class,new DeliveryExecutionGuard(execution));
dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class)));
fees=mock(OverseasFeeSourceService.class);dependencies.put(OverseasFeeSourceService.class,fees);
delivery=wire(new DeliveryTransferService());visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService());
delivery=wire(new DeliveryTransferService());
org.springframework.test.util.ReflectionTestUtils.setField(delivery, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService());
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("OP","", "platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:cancel","platform:overseas-warehouse:dispatch:transfer-delivery"));
}
/** 真实权限上下文不泄漏到其他测试。 */
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.financefee;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.client.FirstlegFeeCatalogClient;
import com.baiyue.lastleg.financefee.service.FeeCatalogGateway;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FeeCatalogGatewayTest {
/** 应付项目按真实方向查询,非法方向不访问远端目录。 */
@Test void listsPayableItemsAndRejectsUnknownDirection() {
FirstlegFeeCatalogClient client = mock(FirstlegFeeCatalogClient.class);
FeeCatalogGateway gateway = new FeeCatalogGateway(client, "token");
when(client.list("token", "PAYABLE")).thenReturn(ApiResponse.success(List.of(
new FirstlegFeeCatalogClient.Item("FEE-2", "供应商运费", BigDecimal.TEN))));
assertEquals("FEE-2", gateway.listActive("PAYABLE").get(0).code());
assertThrows(BizException.class, () -> gateway.listActive("OTHER"));
verify(client).list("token", "PAYABLE");
verifyNoMoreInteractions(client);
}
/** 费用必须使用目录当前编码与名称,不接受伪造或过时项目快照。 */
@Test void validatesTheCurrentCatalogSnapshot() {
FirstlegFeeCatalogClient client = mock(FirstlegFeeCatalogClient.class);
FeeCatalogGateway gateway = new FeeCatalogGateway(client, "token");
FirstlegFeeCatalogClient.Item item = new FirstlegFeeCatalogClient.Item("FEE-1", "操作费", new BigDecimal("2.50"));
when(client.list("token", "RECEIVABLE")).thenReturn(ApiResponse.success(List.of(item)));
when(client.detail("token", "FEE-1", "RECEIVABLE")).thenReturn(ApiResponse.success(item));
assertEquals(item.defaultPrice(), gateway.listActiveReceivable().get(0).defaultPrice());
assertDoesNotThrow(() -> gateway.requireActive("FEE-1", "操作费", "RECEIVABLE"));
assertThrows(BizException.class, () -> gateway.requireActive("FEE-1", "篡改项目名", "RECEIVABLE"));
}
/** 凭证未配置及远端拒绝均关闭录费,不能转为任意费用项。 */
@Test void failsClosedOnMissingIdentityAndRejectedResponses() {
FirstlegFeeCatalogClient client = mock(FirstlegFeeCatalogClient.class);
assertThrows(BizException.class, () -> new FeeCatalogGateway(client, "").listActiveReceivable());
verifyNoInteractions(client);
FeeCatalogGateway gateway = new FeeCatalogGateway(client, "token");
when(client.list("token", "RECEIVABLE")).thenReturn(ApiResponse.failure(ResultCode.FORBIDDEN, "拒绝", null, null));
assertThrows(BizException.class, gateway::listActiveReceivable);
when(client.detail("token", "FEE-1", "RECEIVABLE")).thenReturn(ApiResponse.success(null));
assertThrows(BizException.class, () -> gateway.requireActive("FEE-1", "操作费", "RECEIVABLE"));
}
}
@@ -64,7 +64,7 @@ class OverseasFeeCancellationPersistenceTest {
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(200) UNIQUE,payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32),wallet_settlement VARCHAR(1000),review_reason VARCHAR(500),reviewer_id VARCHAR(128),reviewer_name VARCHAR(128),reviewed_at TIMESTAMP)");
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status) VALUES(10,701,1,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE')");
var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); config.addMapper(OverseasFeeSourceMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet();
@@ -106,6 +106,17 @@ class OverseasFeeCancellationPersistenceTest {
assertThat(ready).hasSize(1);
assertThat(ready.get(0).getEventKey()).isEqualTo(OverseasFeeCancellation.eventKey(source, "PUBLISHED"));
}
/** 旧余额应收即使等待重试也不再发送,并在分页前排除,不挤占账单投递。 */
@Test void collectedReceivablesDoNotOccupyPublicationQueue() {
for (int i = 0; i < 60; i++) {
long id = 2000 + i;
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,direction,wallet_settlement) VALUES(?,?,1,'10001','OVERSEAS_DELIVERY_FEE','RECEIVABLE','{}')", id, id);
jdbc.update("INSERT INTO ll_event_outbox(id,customer_id,aggregate_type,aggregate_id,event_type,event_key,status,created_at) VALUES(?,'10001','OVERSEAS_DELIVERY_FEE',?,?,?,'PENDING',?)",
id, String.valueOf(id), request.getEventType(), "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_DELIVERY_FEE:" + id + ":1:PUBLISHED", now.minusDays(1));
}
assertThat(repository.pending(50, now.plusDays(1), now.minusSeconds(60))).extracting(LastlegEventOutbox::getEventKey)
.containsExactly(OverseasFeeCancellation.eventKey(source, "PUBLISHED"));
}
@Test void manualRetryInShanghaiIsImmediatelySelectableAndClaimableByUtcFeeRelay() {
TimeZone original = TimeZone.getDefault();
try {
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.financefee;
import com.baiyue.lastleg.financefee.config.OverseasFeeResponseJsonConfiguration;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService.RetryRecord;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import static org.assertj.core.api.Assertions.*;
/** 费用与出库时间必须表达同一时刻,业务录入的本地支付时间不能被误转 UTC。 */
class OverseasFeeResponseTimeTest {
@Test void feeSystemTimesAndRetryExposeUtcWithoutChangingStoredValues() throws Exception {
ObjectMapper json = Jackson2ObjectMapperBuilder.json().modulesToInstall(
new OverseasFeeResponseJsonConfiguration().overseasFeeResponseTimeModule()).build();
var utc = LocalDateTime.of(2026, 10, 23, 10, 30);
var fee = new OverseasFeeSource();
fee.setCreatedAt(utc); fee.setReviewedAt(utc); fee.setUpdatedAt(utc); fee.setPaidAt(utc);
var document = json.readTree(json.writeValueAsString(fee));
for (var field : new String[]{"createdAt", "reviewedAt", "updatedAt"}) {
assertThat(document.get(field).asText()).isEqualTo("2026-10-23T10:30:00Z");
assertThat(OffsetDateTime.parse(document.get(field).asText()).toInstant())
.isEqualTo(OffsetDateTime.parse("2026-10-23T18:30:00+08:00").toInstant());
}
assertThat(fee.getCreatedAt()).isEqualTo(utc);
assertThat(document.get("paidAt").asText()).doesNotEndWith("Z");
var retry = json.readTree(json.writeValueAsString(new RetryRecord("key", utc, "审核员", "重试", "SUCCESS", null)));
assertThat(retry.get("requestedAt").asText()).isEqualTo("2026-10-23T10:30:00Z");
fee.setReviewedAt(null);
assertThat(json.readTree(json.writeValueAsString(fee)).get("reviewedAt").isNull()).isTrue();
}
}
@@ -0,0 +1,229 @@
package com.baiyue.lastleg.financefee;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeBusinessMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeFundingMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasManualFeeQueryMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class OverseasManualFeePersistenceTest {
private JdbcTemplate jdbc;
private OverseasFeeFundingMapper funding;
private OverseasManualFeeQueryMapper queries;
private OverseasFeeBusinessMapper businesses;
private OverseasFeeSourceMapper fees;
/** 在隔离内存库验证真正Mapper与DDL,无需启动服务或连接测试环境。 */
@BeforeEach void setup() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(dataSource);
String ddl = Files.readString(Path.of("src/main/resources/db/overseas-manual-fee-funding.sql"))
.replace("command_history JSON", "command_history VARCHAR(10000)");
jdbc.execute("CREATE TABLE ll_destuff_pallet_cost_allocation(fee_source_id BIGINT NOT NULL)");
jdbc.execute(ddl);
jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(128),customer_name VARCHAR(200),business_type VARCHAR(64),business_no VARCHAR(128),fee_item_code VARCHAR(64),direction VARCHAR(16),review_status VARCHAR(16),created_at TIMESTAMP,exchange_rate DECIMAL(20,6),wallet_settlement VARCHAR(1000),payment_voucher_file_ids VARCHAR(1000))");
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),customer_name VARCHAR(200),order_no VARCHAR(128),order_type VARCHAR(32),overseas_warehouse_id VARCHAR(64))");
for (String table : java.util.List.of("ll_warehouse_transfer_order", "ll_return_order", "ll_us_truck_order", "ll_local_looseboard_order", "ll_relabel_application", "ll_cargo_discard_application", "ll_inbound_plan", "ll_delivery_order"))
jdbc.execute("CREATE TABLE " + table + "(customer_id VARCHAR(128))");
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.addMapper(OverseasFeeSourceMapper.class); config.addMapper(OverseasFeeFundingMapper.class);
config.addMapper(OverseasManualFeeQueryMapper.class); config.addMapper(OverseasFeeBusinessMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet();
var session = new SqlSessionTemplate(factory.getObject());
funding = session.getMapper(OverseasFeeFundingMapper.class); queries = session.getMapper(OverseasManualFeeQueryMapper.class);
businesses = session.getMapper(OverseasFeeBusinessMapper.class);
fees = session.getMapper(OverseasFeeSourceMapper.class);
}
/** 指令历史JSON必须随行锁读回,成功时旧错误必须能清空。 */
@Test void fundingHistoryRoundTripsAndSuccessClearsPreviousError() {
var value = new OverseasFeeFunding(); value.setSourceFeeId(701L); value.setCustomerId("10001");
value.setCollectionMode("WALLET"); value.setWalletStatus("FREEZE_FAILED"); value.setCommandVersion(1L);
value.setCommandHistory(new LinkedHashMap<>(Map.of("command-1", "FREEZE"))); value.setErrorMessage("余额不足");
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(LocalDateTime.now()); funding.insert(value);
var locked = funding.lock(701L); assertThat(locked.getCommandHistory()).containsEntry("command-1", "FREEZE");
locked.setErrorMessage(null); locked.setWalletStatus("FROZEN"); funding.updateById(locked);
assertThat(funding.lock(701L).getErrorMessage()).isNull();
}
/** 分页只查最新版本,并保持客户、应收方向和资金状态筛选一致。 */
@Test void pageSelectsLatestVersionAndFiltersCustomerDirectionAndFunding() {
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(1,701,1,'10001','客户甲','WAREHOUSE_TRANSFER','TR-01','OUTBOUND','RECEIVABLE','DRAFT',CURRENT_TIMESTAMP,NULL,NULL)");
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(2,701,2,'10001','客户甲','WAREHOUSE_TRANSFER','TR-01','OUTBOUND','RECEIVABLE','PENDING',CURRENT_TIMESTAMP,'{\"commandEventId\":\"e\",\"holdId\":\"h\",\"ledgerId\":\"l\"}','[\"proof\"]')");
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(3,702,1,'10002','客户乙','WAREHOUSE_TRANSFER','TR-02','OUTBOUND','RECEIVABLE','PENDING',CURRENT_TIMESTAMP,NULL,NULL)");
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(4,703,1,'10001','客户甲','WAREHOUSE_TRANSFER','TR-03','OUTBOUND','PAYABLE','PENDING',CURRENT_TIMESTAMP,NULL,NULL)");
var query = new HashMap<String,Object>(); query.put("customerId", "10001"); query.put("direction", "RECEIVABLE");
query.put("offset", 0); query.put("size", 20);
assertThat(queries.count(query)).isEqualTo(1);
var rows = queries.page(query); assertThat(rows).hasSize(1); assertThat(rows.get(0).getSourceVersion()).isEqualTo(2);
assertThat(rows.get(0).getWalletSettlement().ledgerId()).isEqualTo("l");
assertThat(rows.get(0).getPaymentVoucherFileIds()).containsExactly("proof");
query.put("walletStatus", "SETTLED"); assertThat(queries.count(query)).isEqualTo(1);
query.put("walletStatus", "FREEZE_PENDING"); assertThat(queries.count(query)).isZero();
}
/** 仓租专页只返回已出库且同客户的真实仓租,不混入同名人工费用或未消费准备。 */
@Test void storageRentOnlyRequiresConsumedOwnedPreparation() {
jdbc.execute("CREATE TABLE ll_storage_rent_preparation(source_fee_id BIGINT,customer_id VARCHAR(128),consumed_at TIMESTAMP)");
for (int index = 1; index <= 4; index++)
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,fee_item_code,direction) VALUES(?,?,1,'10001','FEE_TYPE_077','RECEIVABLE')", index, 700 + index);
jdbc.update("INSERT INTO ll_storage_rent_preparation VALUES(701,'10001',CURRENT_TIMESTAMP),(702,'10001',NULL),(703,'10002',CURRENT_TIMESTAMP)");
var query = new HashMap<String,Object>(); query.put("storageRentOnly", true); query.put("direction", "RECEIVABLE");
query.put("offset", 0); query.put("size", 1);
assertThat(queries.count(query)).isEqualTo(1);
assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(701L);
query.put("storageRentOnly", false);
assertThat(queries.count(query)).isEqualTo(4);
}
/** 一件代发使用实际订单类型且归属筛选不可能返回另一客户订单。 */
@Test void orderCandidatesRetainRealTypeAndRejectForeignOwnership() {
jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','DS-01','ONE_CLICK_DROPSHIP','8')");
jdbc.update("INSERT INTO ll_order VALUES(92,'10002','客户乙','FL-01','FIRST_LEG','8')");
var options = businesses.options("LASTLEG_ORDER", "10001", "DS-");
assertThat(options).hasSize(1); assertThat(options.get(0).orderType()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(options.get(0).warehouseId()).isEqualTo(8L);
assertThat(businesses.countOwned("LASTLEG_ORDER", 91, "10002", "DS-01")).isZero();
assertThat(businesses.countCustomerOwned("LASTLEG_ORDER", 91, "10001")).isEqualTo(1);
}
/** 换标分类按订单实体及客户识别,分页和总数同步且不凭单号前缀推断。 */
@Test void relabelClassificationUsesOwnedOrderAndConsistentPageFilter() {
jdbc.execute("ALTER TABLE ll_finance_fee_source ADD business_id BIGINT");
jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','TB-01','RELABEL_APPLICATION','8')");
jdbc.update("INSERT INTO ll_order VALUES(92,'10001','客户甲','TB-02','FIRST_LEG','8')");
jdbc.update("INSERT INTO ll_order VALUES(93,'10002','客户乙','TB-03','RELABEL_APPLICATION','8')");
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_id,business_no,direction) VALUES"
+ "(1,701,1,'10001','LASTLEG_ORDER',91,'TB-01','RECEIVABLE'),"
+ "(2,702,1,'10001','LASTLEG_ORDER',92,'TB-02','RECEIVABLE'),"
+ "(3,703,1,'10001','LASTLEG_ORDER',93,'TB-03','RECEIVABLE'),"
+ "(4,704,1,'10001','RELABEL_ORDER',94,'TB-04','RECEIVABLE')");
assertThat(businesses.relabelFeeIds(java.util.List.of(701L,702L,703L,704L))).containsExactly(701L);
var query = new HashMap<String,Object>(); query.put("customerId", "10001");
query.put("businessType", "RELABEL_ORDER"); query.put("direction", "RECEIVABLE");
query.put("offset", 0); query.put("size", 20);
assertThat(queries.count(query)).isEqualTo(2);
assertThat(queries.page(query)).extracting("sourceFeeId").containsExactlyInAnyOrder(701L,704L);
query.put("businessType", "LASTLEG_ORDER");
assertThat(queries.count(query)).isEqualTo(2);
assertThat(queries.page(query)).extracting("sourceFeeId").containsExactlyInAnyOrder(702L,703L);
}
/** 新费用定位同客户真实换标申请,不能凭相同单号关联其他客户。 */
@Test void relabelOrderResolutionRequiresActualOwnedApplication() {
jdbc.execute("ALTER TABLE ll_relabel_application ADD id BIGINT");
jdbc.execute("ALTER TABLE ll_relabel_application ADD relabel_no VARCHAR(128)");
jdbc.execute("ALTER TABLE ll_relabel_application ADD lastleg_order_id BIGINT");
jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','TB-01','RELABEL_APPLICATION','8')");
jdbc.update("INSERT INTO ll_relabel_application VALUES('10002',501,'TB-01',91)");
assertThat(businesses.relabelForOrder(91, "10001")).isNull();
jdbc.update("INSERT INTO ll_relabel_application VALUES('10001',502,'TB-01',91)");
var business = businesses.relabelForOrder(91, "10001");
assertThat(business.businessId()).isEqualTo(502L);
assertThat(business.businessType()).isEqualTo("RELABEL_ORDER");
assertThat(business.orderId()).isEqualTo(91L);
assertThat(businesses.relabelForOrder(91, "10002")).isNull();
}
/** 业务状态过滤在数据库统一分页,覆盖原扣费失败和原事件推送失败。 */
@Test void businessProcessingFiltersMatchCountsAndPages() {
for (String column : java.util.List.of("source_status VARCHAR(16)", "publication_status VARCHAR(16)", "source_type VARCHAR(64)", "created_by VARCHAR(128)"))
jdbc.execute("ALTER TABLE ll_finance_fee_source ADD " + column);
jdbc.execute("CREATE TABLE ll_event_outbox(event_key VARCHAR(255) PRIMARY KEY,customer_id VARCHAR(128),status VARCHAR(32),last_error VARCHAR(1000))");
String[] statuses = {"WAITING", "PROCESSING", "COMPLETED", "FAILED", "CLOSED"};
for (int index = 0; index < statuses.length; index++) {
long source = 701L + index;
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_no,direction,review_status,source_status,publication_status,source_type) VALUES(?,?,1,'10001','LASTLEG_ORDER','TB-1','PAYABLE',?,'ACTIVE',?,'OVERSEAS_ORDER_FEE')",
index + 1, source, index == 4 ? "REJECTED" : index == 0 ? "PENDING" : "APPROVED", index == 0 || index == 4 ? "UNPUBLISHED" : "PUBLISHED");
if (index > 0 && index < 4) jdbc.update("INSERT INTO ll_event_outbox(event_key,customer_id,status) VALUES(?,'10001',?)", "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_ORDER_FEE:" + source + ":1:PUBLISHED", index == 1 ? "PENDING" : index == 2 ? "PUBLISHED" : "DEAD");
}
Map<String,Object> query = new HashMap<>(); query.put("direction", "PAYABLE"); query.put("offset", 0); query.put("size", 1);
for (int index = 0; index < statuses.length; index++) {
query.put("processingStatus", statuses[index]);
assertThat(queries.count(query)).isEqualTo(1);
assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(701L + index);
}
// 自动重试已发生真实失败时,失败筛选与页面一致;接收后恢复已完成。
jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error='S0500' WHERE event_key LIKE '%:704:1:PUBLISHED'");
query.put("processingStatus", "FAILED"); assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(704L);
jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL WHERE event_key LIKE '%:704:1:PUBLISHED'");
query.put("processingStatus", "FAILED"); assertThat(queries.count(query)).isZero();
query.put("processingStatus", "COMPLETED"); assertThat(queries.count(query)).isEqualTo(2);
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_no,direction,review_status,source_status,publication_status) VALUES(6,706,1,'10001','WAREHOUSE_TRANSFER','WT-1','RECEIVABLE','APPROVED','ACTIVE','UNPUBLISHED')");
var state = new OverseasFeeFunding(); state.setSourceFeeId(706L); state.setCustomerId("10001"); state.setCollectionMode("WALLET"); state.setWalletStatus("SETTLE_FAILED"); state.setCommandVersion(3L); state.setCommandHistory(new LinkedHashMap<>()); state.setCreatedAt(LocalDateTime.now()); state.setUpdatedAt(LocalDateTime.now()); funding.insert(state);
query.put("direction", "RECEIVABLE"); query.put("processingStatus", "FAILED");
assertThat(queries.count(query)).isEqualTo(1); assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(706L);
// 旧费用推送失败不能覆盖已实扣结果,完成筛选和分页必须同步。
jdbc.update("UPDATE ll_finance_fee_source SET source_type='OVERSEAS_ORDER_FEE',publication_status='PUBLISHED' WHERE source_fee_id=706");
jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged:LASTLEG:OVERSEAS_ORDER_FEE:706:1:PUBLISHED','10001','DEAD','旧推送失败')");
state.setWalletStatus("SETTLED"); funding.updateById(state);
assertThat(queries.count(query)).isZero();
query.put("processingStatus", "COMPLETED");
assertThat(queries.count(query)).isEqualTo(1);
assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(706L);
// 驳回不等于资金已释放,失败补偿仍须留在失败筛选,避免被冻结资金隐藏。
jdbc.update("UPDATE ll_finance_fee_source SET review_status='REJECTED' WHERE source_fee_id=706");
state.setWalletStatus("RELEASE_FAILED"); funding.updateById(state);
query.put("processingStatus", "FAILED");
assertThat(queries.count(query)).isEqualTo(1);
assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(706L);
query.put("processingStatus", "CLOSED"); assertThat(queries.count(query)).isZero();
state.setWalletStatus("RELEASE_PENDING"); funding.updateById(state);
query.put("processingStatus", "PROCESSING"); assertThat(queries.count(query)).isEqualTo(1);
state.setWalletStatus("RELEASED"); funding.updateById(state);
query.put("processingStatus", "CLOSED"); assertThat(queries.count(query)).isEqualTo(1);
}
/** 外来货客户即使没有头程订单和历史费用,也必须能新增转运费用。 */
@Test void externalTransferCustomerIsSelectableBeforeAnyFeeExists() {
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('20001')");
assertThat(businesses.customers()).extracting("customerId").containsExactly("20001");
}
/** 同名人工分次费用不拦截,只防止旧自动来源及其公共目录别名再收费。 */
@Test void onlyOldAutomaticProjectBlocksEquivalentCatalogItem() {
for (String column : java.util.List.of("business_id BIGINT","order_id BIGINT","fee_item_name VARCHAR(100)","source_status VARCHAR(16)"))
jdbc.execute("ALTER TABLE ll_finance_fee_source ADD " + column);
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_id,business_no,fee_item_code,fee_item_name,direction,review_status,source_status) VALUES(1,701,1,'10001','DELIVERY_ORDER',91,'DL-1','DELIVERY_FEE','派送费','RECEIVABLE','APPROVED','ACTIVE')");
assertThat(queries.legacyProject("10001","DELIVERY_ORDER",91,"FEE_TYPE_075","派送费",702)).isEqualTo(1);
assertThat(queries.legacyProject("10002","DELIVERY_ORDER",91,"FEE_TYPE_075","派送费",702)).isZero();
assertThat(queries.legacyProject("10001","DELIVERY_ORDER",91,"FEE_TYPE_071","贴标费",702)).isZero();
jdbc.update("UPDATE ll_finance_fee_source SET fee_item_code='FEE_TYPE_075'");
assertThat(queries.legacyProject("10001","DELIVERY_ORDER",91,"FEE_TYPE_075","派送费",702)).isZero();
}
/** 费用说明真实落库;重复提醒仅匹配本客户、业务、方向和最新有效费用。 */
@Test void feeRemarkPersistsAndDuplicateQueryKeepsBusinessScope() {
jdbc.execute("ALTER TABLE ll_finance_fee_source ADD business_id BIGINT");
jdbc.execute("ALTER TABLE ll_finance_fee_source ADD source_status VARCHAR(16)");
jdbc.execute("ALTER TABLE ll_finance_fee_source ADD fee_remark VARCHAR(500)");
var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
fee.setId(1L); fee.setSourceFeeId(701L); fee.setSourceVersion(1); fee.setCustomerId("10001");
fee.setBusinessType("WAREHOUSE_TRANSFER"); fee.setBusinessId(91L); fee.setBusinessNo("TR-1");
fee.setFeeItemCode("HANDLING"); fee.setDirection("RECEIVABLE"); fee.setSourceStatus("ACTIVE");
fee.setReviewStatus("REJECTED"); fee.setFeeRemark("第二次装卸,另收操作费");
fees.insert(fee);
assertThat(fees.lockCurrent(701L).getFeeRemark()).isEqualTo("第二次装卸,另收操作费");
assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 702L)).isEqualTo(1);
assertThat(queries.sameProject("10002", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 702L)).isZero();
assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "PAYABLE", "HANDLING", 702L)).isZero();
assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 92L, "RECEIVABLE", "HANDLING", 702L)).isZero();
assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 701L)).isZero();
jdbc.update("UPDATE ll_finance_fee_source SET source_status='VOIDED'");
assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 702L)).isZero();
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.financefee;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.controller.PlatformOverseasFeeCatalogController;
import com.baiyue.lastleg.financefee.service.FeeCatalogGateway;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import java.util.Set;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PlatformOverseasFeeCatalogControllerTest {
/** HTTP 参数默认应收,切换应付时真实透传到目录服务。 */
@Test void bindsDefaultAndPayableDirection() throws Exception {
FeeCatalogGateway catalog = mock(FeeCatalogGateway.class);
when(catalog.listActive(anyString())).thenReturn(List.of());
var mvc = MockMvcBuilders.standaloneSetup(new PlatformOverseasFeeCatalogController(catalog))
.setCustomArgumentResolvers(new org.springframework.web.method.support.HandlerMethodArgumentResolver() {
/** 为只读平台接口提供测试身份。 */
public boolean supportsParameter(org.springframework.core.MethodParameter parameter) {
return parameter.getParameterType() == AuthenticatedUser.class;
}
/** 构造合法平台主体,不影响查询参数绑定。 */
public Object resolveArgument(org.springframework.core.MethodParameter parameter,
org.springframework.web.method.support.ModelAndViewContainer container,
org.springframework.web.context.request.NativeWebRequest request,
org.springframework.web.bind.support.WebDataBinderFactory binder) {
return new AuthenticatedUser(1L, 0L, 0L, 1L, "tester", "PLATFORM", "WEB", Set.of());
}
}).build();
mvc.perform(get("/api/platform/v1/last-leg/finance-fee-sources/fee-items")).andExpect(status().isOk());
mvc.perform(get("/api/platform/v1/last-leg/finance-fee-sources/fee-items").param("direction", "PAYABLE"))
.andExpect(status().isOk());
verify(catalog).listActive("RECEIVABLE");
verify(catalog).listActive("PAYABLE");
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.financefee;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.controller.PlatformOverseasFeeSourceController;
import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import java.util.Set;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.mockito.Mockito.mock;
class PlatformOverseasFeeSourceControllerSecurityTest {
@Test
void rejectsNonPlatformTenantBeforeReadingFeeData() throws Exception {
var controller = new PlatformOverseasFeeSourceController(
mock(OverseasFeeSourceService.class), mock(OverseasManualFeeService.class),
mock(OverseasFeeDeliveryService.class));
var mvc = MockMvcBuilders.standaloneSetup(controller)
.setCustomArgumentResolvers(identity(new AuthenticatedUser(7L, 42L, 0L, 1L,
"customer-user", "LASTLEG", "WEB", Set.of())))
.build();
mvc.perform(get("/api/platform/v1/last-leg/finance-fee-sources"))
.andExpect(status().isForbidden());
}
private static HandlerMethodArgumentResolver identity(AuthenticatedUser user) {
return new HandlerMethodArgumentResolver() {
@Override public boolean supportsParameter(org.springframework.core.MethodParameter parameter) {
return parameter.getParameterType() == AuthenticatedUser.class;
}
@Override public Object resolveArgument(org.springframework.core.MethodParameter parameter,
org.springframework.web.method.support.ModelAndViewContainer container,
org.springframework.web.context.request.NativeWebRequest request,
org.springframework.web.bind.support.WebDataBinderFactory binder) {
return user;
}
};
}
}
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.financefee;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
class StorageRentZeroFeePersistenceTest {
private OverseasFeeSourceMapper mapper;
private JdbcTemplate jdbc;
/** 使用发布基线原始表定义和真正 Mapper,避免手写简化表遗漏 CHECK。 */
@BeforeEach void setup() throws Exception {
String source=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS ll_finance_fee_source \\((.*?)\\) ENGINE=InnoDB",Pattern.DOTALL).matcher(source);
assertThat(match.find()).isTrue();
String ddl="CREATE TABLE ll_finance_fee_source ("+match.group(1)+")";
ddl=ddl.replaceAll("\\bJSON\\b","VARCHAR(10000)")
.replaceAll("UNIQUE KEY (\\w+)\\(","CONSTRAINT $1 UNIQUE(")
.replaceAll("KEY \\w+\\([^)]*\\),?","");
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);jdbc.execute(ddl);
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(OverseasFeeSourceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasFeeSourceMapper.class);
}
/** 免费美元仓租无需虚构汇率,审核与新版本调价必须真实持久化。 */
@Test void freeRentPersistsWithoutRateAndCanBecomePaidVersion() {
// 模拟已有库默认1:省略NULL字段会把后续USD调价错误固化为1:1。
jdbc.execute("ALTER TABLE ll_finance_fee_source ALTER COLUMN exchange_rate SET DEFAULT 1.00000000");
var free=fee(1);assertThat(mapper.insert(free)).isEqualTo(1);
var saved=mapper.selectById(1L);assertThat(saved.getAmount()).isZero();assertThat(saved.getExchangeRate()).isNull();
assertThat(saved.getReviewStatus()).isEqualTo("PENDING");assertThat(saved.getWalletSettlement()).isNull();
free.setSourceStatus("SUPERSEDED");mapper.updateById(free);
var paid=fee(2);paid.setSourceFeeId(1L);paid.setSourceVersion(2);paid.setOriginalAmount(new BigDecimal("20"));
paid.setAmount(new BigDecimal("142.47"));paid.setExchangeRate(new BigDecimal("7.1235"));paid.setFeeRemark("调整说明");
mapper.insert(paid);paid.setReviewStatus("APPROVED");mapper.updateById(paid);
assertThat(mapper.selectById(2L).getAmount()).isEqualByComparingTo("142.47");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_finance_fee_source",Integer.class)).isEqualTo(2);
}
/** 零元例外仅属于自动应收仓租,人工、其他项目、应付和负额仍被数据库拦截。 */
@Test void zeroExceptionCannotLeakToManualOtherProjectsOrPayables() {
var manual=fee(1);manual.setCreatedBy("admin");assertThatThrownBy(()->mapper.insert(manual)).isInstanceOf(Exception.class);
var other=fee(2);other.setFeeItemCode("OTHER");assertThatThrownBy(()->mapper.insert(other)).isInstanceOf(Exception.class);
var payable=fee(3);payable.setDirection("PAYABLE");assertThatThrownBy(()->mapper.insert(payable)).isInstanceOf(Exception.class);
var negative=fee(4);negative.setAmount(BigDecimal.ONE.negate());assertThatThrownBy(()->mapper.insert(negative)).isInstanceOf(Exception.class);
var unmatched=fee(5);unmatched.setOriginalAmount(BigDecimal.ONE);assertThatThrownBy(()->mapper.insert(unmatched)).isInstanceOf(Exception.class);
}
/** 正额费用必须有效汇率;空值及零汇率不能绕过 SQL 三值逻辑。 */
@Test void nonzeroFeeRequiresPositiveRate() {
var paid=fee(1);paid.setAmount(BigDecimal.ONE);paid.setOriginalAmount(BigDecimal.ONE);
assertThatThrownBy(()->mapper.insert(paid)).isInstanceOf(Exception.class);
paid.setExchangeRate(BigDecimal.ZERO);assertThatThrownBy(()->mapper.insert(paid)).isInstanceOf(Exception.class);
paid.setExchangeRate(BigDecimal.ONE.negate());assertThatThrownBy(()->mapper.insert(paid)).isInstanceOf(Exception.class);
paid.setExchangeRate(BigDecimal.ONE);assertThat(mapper.insert(paid)).isEqualTo(1);
}
private OverseasFeeSource fee(long id) {
var f=new OverseasFeeSource();f.setId(id);f.setSourceFeeId(id);f.setSourceVersion(1);f.setCustomerId("101");
f.setSourceType("OVERSEAS_WAREHOUSE_FEE");f.setSourceNo("ZY-TEST");f.setBusinessType("WAREHOUSE_TRANSFER");f.setBusinessNo("ZY-TEST");
f.setFeeItemCode("FEE_TYPE_077");f.setFeeItemName("仓租");f.setDirection("RECEIVABLE");f.setBusinessDate(LocalDate.of(2026,10,23));
f.setCurrency("CNY");f.setAmount(BigDecimal.ZERO);f.setOriginalCurrency("USD");f.setOriginalAmount(BigDecimal.ZERO);f.setExchangeRateMonth("2026-10");
f.setReviewStatus("PENDING");f.setPublicationStatus("UNPUBLISHED");f.setSourceStatus("ACTIVE");f.setCreatedBy("STORAGE_RENT");f.setUpdatedBy("STORAGE_RENT");
f.setCreatedAt(LocalDateTime.now());f.setUpdatedAt(LocalDateTime.now());return f;
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.WalletSettlementReference;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class LegacyRelabelSettledFeeSourceTest {
private final OverseasFeeSourceMapper mapper=mock(OverseasFeeSourceMapper.class);
private final OverseasFeeOutboxService outbox=mock(OverseasFeeOutboxService.class);
private final OverseasFeeCurrencyConverter converter=mock(OverseasFeeCurrencyConverter.class);
private final OverseasFeeSourceService service=spy(new OverseasFeeSourceService(mapper,mock(OverseasFeeCommandMapper.class),
mock(OverseasFeeBusinessGuard.class),outbox,converter,new ObjectMapper().findAndRegisterModules(),mock(OverseasFeeCancellation.class)));
private final WalletSettlementReference reference=new WalletSettlementReference("command","hold","ledger");
@Test void existingPublishedFeeGetsReferenceWithoutSecondSourceOrPublication() {
var prior=new OverseasFeeSource(); prior.setSourceFeeId(701L); prior.setFeeItemCode("RELABEL_SERVICE");
prior.setDirection("RECEIVABLE"); prior.setSourceStatus("ACTIVE"); prior.setAmount(BigDecimal.TEN);
doReturn(List.of(prior)).when(service).listForBusiness("9","RELABEL_ORDER",11L);
when(mapper.lockCurrent(701L)).thenReturn(prior);
assertThat(service.completeLegacyRelabelSettlement("callback","RELABEL:RL-1",request(),reference)).isSameAs(prior);
assertThat(prior.getWalletSettlement()).isEqualTo(reference);
verify(mapper).updateById(prior); verify(mapper,never()).insert(any(OverseasFeeSource.class)); verifyNoInteractions(outbox);
}
@Test void unpublishedLegacyHoldRecordsPaidReferenceWithoutFinancePublication() {
doReturn(List.of()).when(service).listForBusiness("9","RELABEL_ORDER",11L);
when(converter.convert(eq("CNY"),eq(BigDecimal.TEN),any())).thenReturn(new OverseasFeeCurrencyConverter.Conversion("CNY",BigDecimal.TEN,BigDecimal.ONE,"2026-10",BigDecimal.TEN));
var result=service.completeLegacyRelabelSettlement("callback","RELABEL:RL-1",request(),reference);
assertThat(result.getWalletSettlement()).isEqualTo(reference);
verify(mapper).insert(result); verifyNoInteractions(outbox);
}
private SaveRequest request() {
return new SaveRequest("9","客户","OVERSEAS_RELABEL_FEE","RELABEL_ORDER",11L,"RL-1",12L,null,null,null,null,null,null,
"RELABEL_SERVICE","换标费", null,"RECEIVABLE",null,null,null,null,null,null,null,List.of(),LocalDate.of(2026,10,22),
"CNY",BigDecimal.TEN,BigDecimal.ONE,"BOX",BigDecimal.TEN,null,null);
}
}
@@ -51,7 +51,7 @@ class OverseasContainerFeeSourceServiceTest {
SaveRequest request = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(),
source.businessType(), source.businessId(), source.businessNo(), source.orderId(), source.inboundId(),
source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(),
source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(),
source.warehouseId(), "CUSTOM_FEE", "自定义费", null, source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
@@ -69,4 +69,15 @@ class OverseasContainerFeeSourceServiceTest {
verify(mapper).selectList(any());
}
/** 旧提交入口也必须关闭驳回记录,不能绕过人工费用服务清空审核留痕。 */
@Test void rejectedLegacySourceCannotBeResubmitted() {
var fee = new OverseasFeeSource(); fee.setSourceFeeId(701L); fee.setSourceType("OVERSEAS_ORDER_FEE");
fee.setBusinessType("LASTLEG_ORDER"); fee.setReviewStatus("REJECTED"); fee.setReviewReason("金额录错");
fee.setReviewerName("审核员"); when(mapper.lockCurrent(701L)).thenReturn(fee);
assertThatThrownBy(() -> service.submit(701L, "operator", "legacy-rejected"))
.hasMessageContaining("已驳回费用不能重新提交");
assertThat(fee.getReviewReason()).isEqualTo("金额录错"); assertThat(fee.getReviewerName()).isEqualTo("审核员");
org.mockito.Mockito.verify(mapper, org.mockito.Mockito.never()).updateById(any(OverseasFeeSource.class));
}
}
@@ -1,127 +1,67 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OverseasFeeBusinessGuardTest {
private final OverseasFeeBusinessRepository repository = mock(OverseasFeeBusinessRepository.class);
private final OverseasFeeBusinessGuard guard = new OverseasFeeBusinessGuard(repository);
@Test
void acceptsOnlyARealContainerOwnedByTheCustomer() {
StubJdbc jdbc = new StubJdbc(3, 0, 1);
new OverseasFeeBusinessGuard(jdbc).requireOwned(containerFee("customer-a", 701L, "MSCU1234567"));
assertThat(jdbc.sql()).hasSize(3);
assertThat(jdbc.sql().get(0)).contains("source_container_booking_id");
assertThat(jdbc.sql().get(1)).contains("container_no");
assertThat(jdbc.sql().get(2)).contains("customer_id");
@Test void acceptsOnlyARealContainerOwnedByTheCustomer() {
when(repository.container("701")).thenReturn(3);
when(repository.containerCustomer("701", "customer-a")).thenReturn(1);
guard.requireOwned(containerFee("customer-a", 701L, "MSCU1234567"));
verify(repository).inconsistentContainer("701", "MSCU1234567");
verify(repository).containerCustomer("701", "customer-a");
}
@Test
void rejectsAContainerThatDoesNotContainTheClaimedCustomer() {
StubJdbc jdbc = new StubJdbc(3, 0, 0);
assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc)
.requireOwned(containerFee("other-customer", 701L, "MSCU1234567")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("该客户不属于费用关联的真实货柜");
@Test void rejectsAContainerThatDoesNotContainTheClaimedCustomer() {
when(repository.container("701")).thenReturn(3);
assertThatThrownBy(() -> guard.requireOwned(containerFee("other", 701L, "MSCU1234567")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("该客户不属于费用关联的真实货柜");
}
@Test
void rejectsAContainerWhoseOrdersDoNotShareTheClaimedContainerNumber() {
StubJdbc jdbc = new StubJdbc(3, 1);
assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc)
.requireOwned(containerFee("customer-a", 701L, "MSCU1234567")))
.isInstanceOf(ResponseStatusException.class)
@Test void rejectsInconsistentContainerBeforeCustomerLookup() {
when(repository.container("701")).thenReturn(3);
when(repository.inconsistentContainer("701", "MSCU1234567")).thenReturn(1);
assertThatThrownBy(() -> guard.requireOwned(containerFee("customer-a", 701L, "MSCU1234567")))
.hasMessageContaining("真实货柜的集装箱号缺失或不一致");
verify(repository, never()).containerCustomer(anyString(), anyString());
}
@Test
void rejectsDifferentBusinessAndContainerBookingIdentifiersBeforeQuerying() {
@Test void rejectsDifferentBusinessAndContainerBookingIdentifiersBeforeQuerying() {
SaveRequest source = containerFee("customer-a", 701L, "MSCU1234567");
SaveRequest inconsistent = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(),
source.businessType(), 702L, source.businessNo(), source.orderId(), source.inboundId(),
source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(),
source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(),
source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.feeRemark(), source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd());
StubJdbc jdbc = new StubJdbc();
assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("真实来源柜ID");
assertThat(jdbc.sql()).isEmpty();
assertThatThrownBy(() -> guard.requireOwned(inconsistent)).hasMessageContaining("真实来源柜ID");
verifyNoInteractions(repository);
}
@Test
void listsAllContainerCustomersWithOneGroupedQuery() {
StubJdbc jdbc = new StubJdbc();
jdbc.queryResults = List.of(
new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-a", "客户A"),
new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-b", "客户B"));
var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L);
assertThat(result).extracting("customerId").containsExactly("customer-a", "customer-b");
assertThat(jdbc.sql()).singleElement().asString().contains("GROUP BY customer_id");
@Test void listsAllContainerCustomersInOneRepositoryCall() {
when(repository.containerCustomers("701")).thenReturn(List.of(new ContainerCustomerOption("100", "客户甲")));
assertThat(guard.listContainerCustomers(701L)).extracting("customerId").containsExactly("100");
verify(repository).containerCustomers("701");
}
@Test
void returnsEmptyWhenContainerHasNoLastlegOrders() {
StubJdbc jdbc = new StubJdbc();
var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L);
assertThat(result).isEmpty();
assertThat(jdbc.sql()).singleElement().asString().contains("source_container_booking_id");
@Test void returnsEmptyForAnUnknownContainer() {
assertThat(guard.listContainerCustomers(701L)).isEmpty();
}
static SaveRequest containerFee(String customerId, Long bookingId, String containerNo) {
return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER",
bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L,
"PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null,
"PORT_FEE", "港口费", null, "RECEIVABLE", null, null, null, null, null, null, null,
List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"),
null, null, null, null, null);
}
private static final class StubJdbc extends JdbcTemplate {
private final ArrayDeque<Integer> results = new ArrayDeque<>();
private final List<String> sql = new ArrayList<>();
private List<?> queryResults = List.of();
private StubJdbc(Integer... values) {
results.addAll(List.of(values));
}
@Override
public <T> T queryForObject(String statement, Class<T> requiredType, Object... args) {
sql.add(statement);
return requiredType.cast(results.removeFirst());
}
@Override
@SuppressWarnings("unchecked")
public <T> List<T> query(String statement, RowMapper<T> rowMapper, Object... args) {
sql.add(statement);
return (List<T>) queryResults;
}
private List<String> sql() {
return sql;
}
}
}
@@ -0,0 +1,81 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.financefee.client.OverseasFeeCustomerClient;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OverseasFeeCustomerNamesTest {
private final OverseasFeeCustomerClient client = mock(OverseasFeeCustomerClient.class);
private final OverseasFeeCustomerNames names = new OverseasFeeCustomerNames(client, "token");
/** 空快照同客户只请求一次,长整型客户 ID 不经过浮点转换。 */
@Test void resolvesDistinctCustomersByExactId() {
String id = "9223372036854775002";
when(client.customers("token", 1, 1, Long.parseLong(id))).thenReturn(ApiResponse.success(
new PageResult<>(List.of(new OverseasFeeCustomerClient.Customer(Long.parseLong(id), " 柏悦客户 ")),
1, 1, 1, 1)));
assertThat(names.resolve(List.of(customer(id, null), customer(id, " "))))
.containsExactly(entry(id, "柏悦客户"));
verify(client, times(1)).customers("token", 1, 1, Long.parseLong(id));
verifyNoMoreInteractions(client);
}
/** 有旧名称也必须按精确客户 ID 查主档,混合列表和单条详情不能展示不同客户名称。 */
@Test void currentMasterNameOverridesSnapshotsRegardlessOfPageComposition() {
when(client.customers("token", 1, 1, 10001L)).thenReturn(ApiResponse.success(
new PageResult<>(List.of(new OverseasFeeCustomerClient.Customer(10001L, "客户-66")), 1, 1, 1, 1)));
assertThat(names.resolve(List.of(customer("10001", "旧客户-91"), customer("10001", null), customer("10001", "客户-66"))))
.containsExactly(entry("10001", "客户-66"));
assertThat(names.resolve(List.of(customer("10001", null)))).containsExactly(entry("10001", "客户-66"));
verify(client, times(2)).customers("token", 1, 1, 10001L);
verifyNoMoreInteractions(client);
}
/** 远端返回其他客户时不得错配名称,失效引用不伪造客户名。 */
@Test void neverUsesAnotherCustomersName() {
when(client.customers("token", 1, 1, 10001L)).thenReturn(ApiResponse.success(
new PageResult<>(List.of(new OverseasFeeCustomerClient.Customer(10002L, "其他客户")), 1, 1, 1, 1)));
assertThat(names.resolve(List.of(customer("10001", null)))).isEmpty();
}
/** 内部服务故障时保留可用快照,避免逐客户放大超时或阻断费用查询。 */
@Test void stopsRemoteLookupsOnFailureAndRetriesOnNextRead() {
when(client.customers("token", 1, 1, 10001L))
.thenThrow(new IllegalStateException("暂不可用"))
.thenReturn(ApiResponse.success(new PageResult<>(
List.of(new OverseasFeeCustomerClient.Customer(10001L, "恢复后名称")), 1, 1, 1, 1)));
var customers = List.of(customer("10001", null), customer("10002", null), customer("10003", "已有名称"));
assertThat(names.resolve(customers)).containsExactly(entry("10003", "已有名称"));
verify(client, never()).customers("token", 1, 1, 10002L);
assertThat(names.resolve(List.of(customer("10001", null)))).containsEntry("10001", "恢复后名称");
}
/** 业务失败响应不能作为有效主档,后续查询允许恢复。 */
@Test void rejectedOrEmptyResponsesDoNotProduceNames() {
when(client.customers("token", 1, 1, 10001L)).thenReturn(
ApiResponse.failure(ResultCode.FORBIDDEN, "拒绝", null, null), ApiResponse.success(null));
assertThat(names.resolve(List.of(customer("10001", null)))).isEmpty();
assertThat(names.resolve(List.of(customer("10001", null)))).isEmpty();
}
/** 非法客户引用及缺失服务身份不发送匿名请求。 */
@Test void skipsInvalidIdsAndMissingToken() {
assertThat(names.resolve(List.of(customer("invalid", null), customer("0", null), customer(null, null))))
.isEmpty();
assertThat(new OverseasFeeCustomerNames(client, "").resolve(List.of(customer("10001", null)))).isEmpty();
verifyNoInteractions(client);
}
/** 构造只有客户身份与名称的查询快照。 */
private static ContainerCustomerOption customer(String id, String name) {
return new ContainerCustomerOption(id, name);
}
}
@@ -0,0 +1,318 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.*;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeRetryMapper;
import com.baiyue.lastleg.financefee.repository.OverseasFeeDeliveryRepository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OverseasFeeDeliveryServiceTest {
private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
private final OverseasManualFeeRepository fees = mock(OverseasManualFeeRepository.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private JdbcTemplate jdbc;
private OverseasFeeDeliveryService service;
private OverseasFeeSource fee;
private LastlegEventOutbox row;
private String eventId;
private TransactionTemplate transaction;
/** 使用隔离内存库验证真实行锁、审计唯一约束及不变事件重投。 */
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),event_key VARCHAR(255) UNIQUE,event_type VARCHAR(128),aggregate_id VARCHAR(128),aggregate_type VARCHAR(128),payload_json TEXT,status VARCHAR(32),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(128),locked_at TIMESTAMP)");
jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(64),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(128),payload_hash VARCHAR(80),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_wallet_event_inbox(command_event_id VARCHAR(64),status VARCHAR(16),payload_json TEXT,processed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_storage_rent_preparation(id BIGINT PRIMARY KEY,source_fee_id BIGINT,customer_id VARCHAR(128))");
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36),event_id VARCHAR(64),outcome VARCHAR(32),started_at TIMESTAMP)");
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
configuration.setDefaultScriptingLanguage(H2BinaryLanguageDriver.class);
configuration.addMapper(OverseasFeeRetryMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); factory.afterPropertiesSet();
var session = new SqlSessionTemplate(factory.getObject());
service = new OverseasFeeDeliveryService(new OverseasFeeDeliveryRepository(mapper, session.getMapper(OverseasFeeRetryMapper.class)), fees, json);
transaction = new TransactionTemplate(new DataSourceTransactionManager(ds));
fee = new OverseasFeeSource(); fee.setSourceFeeId(701L); fee.setSourceVersion(1); fee.setCustomerId("10001");
fee.setSourceType("OVERSEAS_ORDER_FEE"); fee.setDirection("PAYABLE"); fee.setSourceStatus("ACTIVE");
fee.setReviewStatus("APPROVED"); fee.setPublicationStatus("PUBLISHED"); fee.setAllowedActions(List.of());
eventId = UUID.randomUUID().toString();
String key = EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:OVERSEAS_ORDER_FEE:701:1:PUBLISHED";
var payload = json.createObjectNode().put("sourceSystem", "LASTLEG").put("sourceType", fee.getSourceType())
.put("sourceFeeId", "701").put("sourceVersion", 1).put("customerId", "10001");
saveEvent(key, EventTypes.BUSINESS_FEE_SOURCE_CHANGED, fee.getSourceType(), "701", payload);
when(fees.lockFee(701L)).thenReturn(fee); when(fees.funding(anyCollection())).thenReturn(List.of());
when(fees.current(701L)).thenReturn(fee);
when(mapper.findByEventKey(anyString())).thenAnswer(call -> row.getEventKey().equals(call.getArgument(0)) ? currentRow() : null);
when(mapper.selectList(any())).thenAnswer(call -> List.of(currentRow()));
}
/** 同一事件的并发重试只产生一条审计,载荷、事件编号保持原值。 */
@Test void concurrentDuplicateRetryPreservesEventAndCreatesOneAudit() throws Exception {
var request = request();
String original = row.getPayloadJson();
var pool = Executors.newFixedThreadPool(2);
try {
var first = pool.submit(() -> retry(request));
var second = pool.submit(() -> retry(request));
first.get(10, TimeUnit.SECONDS); second.get(10, TimeUnit.SECONDS);
} finally { pool.shutdownNow(); }
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class)).isEqualTo(original);
assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PENDING");
assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件");
verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any());
}
/** 已成功、正在发送、旧版本、串事件或跨费用请求全部不能产生第二笔收费。 */
@Test void refusesSuccessfulPendingStaleOrUnrelatedRetries() {
for (String status : List.of("PUBLISHED", "PROCESSING", "PENDING")) {
jdbc.update("UPDATE ll_event_outbox SET status=?,last_error=NULL", status);
assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件");
}
jdbc.update("UPDATE ll_event_outbox SET status='DEAD'");
assertThatThrownBy(() -> retry(new RetryDeliveryRequest(2, eventId, UUID.randomUUID().toString()))).hasMessageContaining("版本");
assertThatThrownBy(() -> retry(new RetryDeliveryRequest(1, "other-event", UUID.randomUUID().toString()))).hasMessageContaining("投递记录已变化");
fee.setSourceFeeId(702L);
assertThatThrownBy(() -> retry(request())).hasMessageContaining("没有可重试");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero();
}
/** 列表只对真实失败且摘要完整的当前事件开放重试。 */
@Test void describesFailureAndDisablesCorruptOrRejectedEvents() {
service.describe(List.of(fee));
assertThat(fee.getDeliveryStatus()).isEqualTo("DEAD"); assertThat(fee.getAllowedActions()).contains("RETRY_PUBLISH");
fee.setAllowedActions(List.of()); jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error=NULL");
service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).isEmpty();
jdbc.update("UPDATE ll_event_outbox SET status='DEAD',payload_json=REPLACE(payload_json,'10001','10002')");
service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).isEmpty();
assertThatThrownBy(() -> retry(request())).hasMessageContaining("不一致");
fee.setReviewStatus("REJECTED"); service.describe(List.of(fee)); assertThat(fee.getDeliveryStatus()).isNull();
}
/** 每次失败后都能重新推送同一事件;同键重放和未失败的待发送事件不能重复排队。 */
@Test void failedRetryCanBeRetriedAgainBeforeAutomaticBackoffExpires() {
String original = row.getPayloadJson();
retry(request());
jdbc.update("UPDATE ll_event_outbox SET status='PENDING',retry_count=1,last_error='财务暂不可用',next_retry_at=DATEADD('HOUR',1,CURRENT_TIMESTAMP)");
service.describe(List.of(fee));
assertThat(fee.getDeliveryStatus()).isEqualTo("PENDING");
assertThat(fee.getAllowedActions()).contains("RETRY_PUBLISH");
var second = request(); retry(second); retry(second);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class)).isEqualTo(original);
assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isZero();
service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).doesNotContain("RETRY_PUBLISH");
assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件");
jdbc.update("UPDATE ll_event_outbox SET status='PROCESSING',last_error='旧失败'");
service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).doesNotContain("RETRY_PUBLISH");
assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件");
verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any());
}
/** 已实扣应收不受旧费用推送失败影响,禁止再次投递至账单结算。 */
@Test void collectedReceivableIgnoresOldPublicationFailureAndCannotRepublish() {
retry(request());
fee.setDirection("RECEIVABLE");
fee.setWalletSettlement(new WalletSettlementReference("settled-command", "hold", "ledger"));
fee.setAllowedActions(List.of("RETRY_PUBLISH"));
service.describe(List.of(fee));
assertThat(fee.getDeliveryStatus()).isNull();
assertThat(fee.getAllowedActions()).isEmpty();
assertThat(service.history(701L)).isEmpty();
assertThatThrownBy(() -> retry(request())).hasMessageContaining("没有可重试");
verify(fees, never()).updateFunding(any());
}
/** 资金指令发送失败重投原指令,不能再次创建冻结或结算指令。 */
@Test void retriesOriginalWalletEventWithoutChangingFunding() throws Exception {
fee.setDirection("RECEIVABLE"); fee.setPublicationStatus("UNPUBLISHED");
var funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCollectionMode("WALLET");
funding.setWalletStatus("SETTLE_PENDING"); funding.setCommandVersion(3L); funding.setCommandEventId(eventId); funding.setWalletBusinessNo("701-1");
when(fees.lockFunding(701L)).thenReturn(funding); when(fees.funding(anyCollection())).thenReturn(List.of(funding));
var payload = json.createObjectNode().put("customerId", 10001).put("businessType", "OVERSEAS_MANUAL_FEE").put("businessNo", "701-1").put("operation", "SETTLE");
jdbc.execute("DELETE FROM ll_event_outbox");
saveEvent("WALLET_COMMAND:10001:OVERSEAS_MANUAL_FEE:701-1:SETTLE:3", EventTypes.WALLET_COMMAND_REQUESTED, "OVERSEAS_MANUAL_FEE", "701-1", payload);
service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).contains("RETRY_FUNDING_DELIVERY");
retry(request());
assertThat(funding.getCommandEventId()).isEqualTo(eventId); assertThat(funding.getCommandVersion()).isEqualTo(3L);
verify(fees, never()).updateFunding(any());
}
/** 已终止费用仅重投原冻结或释放补偿指令,不能恢复扣款。 */
@Test void terminatedFeesCanRecoverOriginalFreezeAndReleaseButNeverSettlement() throws Exception {
fee.setDirection("RECEIVABLE"); fee.setPublicationStatus("UNPUBLISHED");
var funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCollectionMode("WALLET");
funding.setCommandVersion(3L); funding.setCommandEventId(eventId); funding.setWalletBusinessNo("701-1");
when(fees.lockFunding(701L)).thenReturn(funding); when(fees.funding(anyCollection())).thenReturn(List.of(funding));
for (String terminal : List.of("REJECTED", "VOIDED")) {
fee.setReviewStatus("REJECTED".equals(terminal) ? "REJECTED" : "PENDING");
fee.setSourceStatus("VOIDED".equals(terminal) ? "VOIDED" : "ACTIVE");
for (String operation : List.of("FREEZE", "RELEASE", "SETTLE")) {
funding.setWalletStatus(operation + "_PENDING");
jdbc.execute("DELETE FROM ll_event_outbox");
var payload = json.createObjectNode().put("customerId",10001).put("businessType","OVERSEAS_MANUAL_FEE")
.put("businessNo","701-1").put("operation",operation);
saveEvent("WALLET_COMMAND:10001:OVERSEAS_MANUAL_FEE:701-1:" + operation + ":3",
EventTypes.WALLET_COMMAND_REQUESTED,"OVERSEAS_MANUAL_FEE","701-1",payload);
service.describe(List.of(fee));
if ("SETTLE".equals(operation)) {
assertThat(fee.getAllowedActions()).doesNotContain("RETRY_FUNDING_DELIVERY");
assertThatThrownBy(() -> retry(request())).hasMessageContaining("状态已变化");
} else {
assertThat(fee.getAllowedActions()).contains("RETRY_FUNDING_DELIVERY");
String original = row.getPayloadJson();
retry(request());
assertThat(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox",String.class)).isEqualTo(original);
assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox",String.class)).isEqualTo("PENDING");
}
}
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit",Integer.class)).isEqualTo(4);
verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any());
}
/** 仓租实额结算重投原资金指令,历史按原预占关联,不能误用人工费用身份。 */
@Test void rentRetriesActualSettlementWithOriginalIdentityAndHistory() throws Exception {
fee.setCreatedBy("STORAGE_RENT");fee.setFeeItemCode("FEE_TYPE_077");fee.setDirection("RECEIVABLE");fee.setPublicationStatus("UNPUBLISHED");
var funding=new OverseasFeeFunding();funding.setSourceFeeId(701L);funding.setCollectionMode("WALLET");funding.setWalletStatus("SETTLE_PENDING");
funding.setCommandVersion(3L);funding.setCommandEventId(eventId);funding.setWalletBusinessNo("8031");
when(fees.lockFunding(701L)).thenReturn(funding);when(fees.funding(anyCollection())).thenReturn(List.of(funding));
var payload=json.createObjectNode().put("customerId",10001).put("businessType","STORAGE_RENT").put("businessNo","8031").put("operation","SETTLE_ACTUAL");
jdbc.execute("DELETE FROM ll_event_outbox");jdbc.update("INSERT INTO ll_storage_rent_preparation VALUES(8031,701,'10001')");
saveEvent("WALLET_COMMAND:10001:STORAGE_RENT:8031:SETTLE_ACTUAL:3",EventTypes.WALLET_COMMAND_REQUESTED,"STORAGE_RENT","8031",payload);
service.describe(List.of(fee));assertThat(fee.getDeliveryKind()).isEqualTo("FUNDING");assertThat(fee.getAllowedActions()).contains("RETRY_FUNDING_DELIVERY");
retry(request());assertThat(service.history(701L)).hasSize(1);verify(fees,never()).updateFunding(any());
fee.setCustomerId("10002");assertThat(service.history(701L)).isEmpty();
}
/** 保留每次重试时间、人员和当次结果;后续成功不能覆盖上次失败。 */
@Test void historyRetainsEachRetryOutcomeAndScopesCustomer() {
var first = request(); retry(first);
var firstAt = java.time.LocalDateTime.now().minusMinutes(3).withNano(0);
jdbc.update("UPDATE event_delivery_retry_audit SET requested_at=? WHERE request_id=?", firstAt, first.idempotencyKey());
jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)", UUID.randomUUID().toString(),eventId,"DEAD",firstAt.plusSeconds(1));
jdbc.update("UPDATE ll_event_outbox SET status='DEAD',last_error='第一次连接失败'");
var second = request(); retry(second);
var secondAt = firstAt.plusMinutes(1);
jdbc.update("UPDATE event_delivery_retry_audit SET requested_at=?,operator_name='李四 [2]' WHERE request_id=?",secondAt,second.idempotencyKey());
jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)",UUID.randomUUID().toString(),eventId,"APPLIED",secondAt.plusSeconds(1));
jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL");
var history = service.history(701L);
assertThat(history).hasSize(2); assertThat(history.get(0).result()).isEqualTo("SUCCESS");
assertThat(history.get(0).operatorName()).isEqualTo("李四 [2]"); assertThat(history.get(0).requestedAt()).isEqualTo(secondAt);
assertThat(history.get(1).result()).isEqualTo("FAILED"); assertThat(history.get(1).errorMessage()).isEqualTo("第一次连接失败");
fee.setCustomerId("10002"); assertThat(service.history(701L)).isEmpty();
}
/** 自动重试每次失败可见,最终接收后才变为成功,并且不重新收费。 */
@Test void historyShowsFailedAutomaticAttemptThenActualAcknowledgement() {
retry(request());
assertThat(service.history(701L).get(0).result()).isEqualTo("SUBMITTED");
var requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", java.time.LocalDateTime.class);
jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)", UUID.randomUUID().toString(), eventId, "RETRY", requested.plusSeconds(1));
jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error='Consumer rejected event: code=S0500'");
var failed = service.history(701L).get(0);
assertThat(failed.result()).isEqualTo("FAILED"); assertThat(failed.errorMessage()).contains("S0500");
jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)", UUID.randomUUID().toString(), eventId, "APPLIED", requested.plusSeconds(2));
jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL");
var succeeded = service.history(701L).get(0);
assertThat(succeeded.result()).isEqualTo("SUCCESS"); assertThat(succeeded.errorMessage()).isNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any());
}
/** 没有投递日志时,仍可由本轮重试后的实际错误识别失败。 */
@Test void historyShowsCurrentErrorWhileAutomaticRetryRemainsPending() {
retry(request());
jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error='S0500'");
assertThat(service.history(701L).get(0).result()).isEqualTo("FAILED");
}
/** 钱包发送成功只算提交,必须读取资金回执才能显示扣款成功或失败。 */
@Test void fundingHistoryUsesActualWalletResult() throws Exception {
fee.setDirection("RECEIVABLE"); fee.setPublicationStatus("UNPUBLISHED");
var funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCollectionMode("WALLET");
funding.setWalletStatus("SETTLE_PENDING"); funding.setCommandVersion(3L); funding.setCommandEventId(eventId); funding.setWalletBusinessNo("701-1");
var payload = json.createObjectNode().put("customerId",10001).put("businessType","OVERSEAS_MANUAL_FEE").put("businessNo","701-1").put("operation","SETTLE");
jdbc.execute("DELETE FROM ll_event_outbox");
saveEvent("WALLET_COMMAND:10001:OVERSEAS_MANUAL_FEE:701-1:SETTLE:3",EventTypes.WALLET_COMMAND_REQUESTED,"OVERSEAS_MANUAL_FEE","701-1",payload);
transaction.executeWithoutResult(status -> service.recordFundingRetry(fee,funding,"张三 [1]","retry-key"));
jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED'");
assertThat(service.history(701L).get(0).result()).isEqualTo("SUBMITTED");
var completed = json.createObjectNode().put("commandEventId",eventId).put("customerId",10001).put("result","FAILED").put("errorMessage","可用额度不足");
var envelope = json.createObjectNode().set("payload",completed);
jdbc.update("INSERT INTO ll_wallet_event_inbox VALUES(?,'PROCESSED',?,CURRENT_TIMESTAMP)",eventId,json.writeValueAsString(envelope));
var actual = service.history(701L).get(0);
assertThat(actual.operatorName()).isEqualTo("张三 [1]"); assertThat(actual.action()).isEqualTo("重试扣费");
assertThat(actual.result()).isEqualTo("FAILED"); assertThat(actual.errorMessage()).isEqualTo("可用额度不足");
}
/** 标识必须完整匹配,不能将近似事件、其他生产表或带后缀的记录号归入本费用。 */
@Test void historyRequiresExactEntryAndEventIdentity() {
retry(request());
jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL");
jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,CURRENT_TIMESTAMP)",
UUID.randomUUID().toString(),eventId + "-other","DEAD");
jdbc.update("INSERT INTO ll_wallet_event_inbox VALUES(?,'PROCESSED',?,CURRENT_TIMESTAMP)",
eventId + "-other","{}");
assertThat(service.history(701L)).singleElement().satisfies(record -> {
assertThat(record.result()).isEqualTo("SUBMITTED");
assertThat(record.errorMessage()).isNull();
});
jdbc.update("UPDATE event_delivery_retry_audit SET entry_id='901-other'");
assertThat(service.history(701L)).isEmpty();
jdbc.update("UPDATE event_delivery_retry_audit SET entry_id='901',producer_table='other_outbox'");
assertThat(service.history(701L)).isEmpty();
}
/** H2的无长度BINARY默认截为一字节,使用等价变长二进制保留MySQL的完整标识比较语义。 */
public static class H2BinaryLanguageDriver extends org.apache.ibatis.scripting.xmltags.XMLLanguageDriver {
/** 仅替换内存库类型名称,不改生产查询的关联、筛选或参数。 */
@Override public org.apache.ibatis.mapping.SqlSource createSqlSource(org.apache.ibatis.session.Configuration configuration,
String script, Class<?> parameterType) {
return super.createSqlSource(configuration, script.replace(" AS BINARY)", " AS VARBINARY)"), parameterType);
}
}
private RetryDeliveryRequest request() { return new RetryDeliveryRequest(1, eventId, UUID.randomUUID().toString()); }
private void retry(RetryDeliveryRequest request) { transaction.executeWithoutResult(status -> service.retry(701L, "operator", request)); }
/** 模拟列表读取当前投递状态及错误,避免复用上次失败快照。 */
private LastlegEventOutbox currentRow() {
row.setStatus(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class));
row.setLastError(jdbc.queryForObject("SELECT last_error FROM ll_event_outbox", String.class));
row.setPayloadJson(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class));
return row;
}
private void saveEvent(String key, String type, String aggregateType, String aggregateId, com.fasterxml.jackson.databind.JsonNode payload) throws Exception {
var event = new EventEnvelope(eventId, type, 1, Instant.now(), "10001", aggregateType, aggregateId, EventTypes.WALLET_COMMAND_REQUESTED.equals(type) ? 3L : 1L, null,
UUID.randomUUID().toString(), key, null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
row = new LastlegEventOutbox(); row.setId(901L); row.setCustomerId("10001"); row.setEventType(type); row.setEventKey(key);
row.setStatus("DEAD"); row.setPayloadJson(json.writeValueAsString(event)); row.setLastError("连接失败");
jdbc.update("INSERT INTO ll_event_outbox(id,customer_id,event_key,event_type,aggregate_id,aggregate_type,payload_json,status,retry_count,last_error) VALUES(901,'10001',?,?,?,?,?,'DEAD',12,'连接失败')", key,type,aggregateId,aggregateType,row.getPayloadJson());
}
}
@@ -0,0 +1,513 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository;
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OverseasManualFeeServiceTest {
private final OverseasFeeSourceService sources = mock(OverseasFeeSourceService.class);
private final OverseasManualFeeRepository repository = mock(OverseasManualFeeRepository.class);
private final OverseasFeeBusinessRepository businesses = mock(OverseasFeeBusinessRepository.class);
private final FeeCatalogGateway catalog = mock(FeeCatalogGateway.class);
private final WalletCommandOutboxService wallet = mock(WalletCommandOutboxService.class);
private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class);
private final OverseasFeeCustomerNames customerNames = mock(OverseasFeeCustomerNames.class);
private final OverseasManualFeeService service = new OverseasManualFeeService(sources, repository, businesses,
catalog, wallet, outbox, new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class), customerNames, mock(OverseasFeeDeliveryService.class));
private OverseasFeeSource fee;
private OverseasFeeFunding funding;
/** 列表与详情均按客户 ID 补齐名称,查询不修改费用或资金记录。 */
@Test void fillsMissingCustomerNameForPageAndDetail() {
when(customerNames.resolve(anyCollection())).thenReturn(java.util.Map.of("10001", "柏悦客户"));
when(repository.page(anyMap())).thenReturn(List.of(fee));
when(repository.count(anyMap())).thenReturn(1L);
PageResult<OverseasFeeSource> page = service.page(1, 10, null, null, null, null, null, null, null, null);
assertThat(page.records().get(0).getCustomerName()).isEqualTo("柏悦客户");
fee.setCustomerName(" ");
assertThat(service.detail(701L).getCustomerName()).isEqualTo("柏悦客户");
verify(repository, never()).updateFee(any());
verify(repository, never()).updateFunding(any());
verifyNoInteractions(wallet, outbox);
}
/** 已有换标派送费只补显示分类,原收费身份、金额及资金状态保持原值。 */
@Test void relabelDeliveryDisplaysRealTypeWithoutRewritingFinancialIdentity() {
fee.setBusinessType("LASTLEG_ORDER"); fee.setBusinessNo("TB202610220001");
fee.setFeeItemName("派送费"); fee.setAmount(new BigDecimal("66.70"));
when(businesses.relabelFeeIds(List.of(701L))).thenReturn(List.of(701L));
when(repository.page(anyMap())).thenReturn(List.of(fee)); when(repository.count(anyMap())).thenReturn(1L);
var row = service.page(1, 20, "10001", "RELABEL_ORDER", null, null, null, null, null, null).records().get(0);
assertThat(row.getDisplayBusinessType()).isEqualTo("RELABEL_ORDER");
assertThat(service.detail(701L).getDisplayBusinessType()).isEqualTo("RELABEL_ORDER");
assertThat(row.getBusinessType()).isEqualTo("LASTLEG_ORDER");
assertThat(row.getBusinessId()).isEqualTo(91L); assertThat(row.getBusinessNo()).isEqualTo("TB202610220001");
assertThat(row.getFeeItemName()).isEqualTo("派送费"); assertThat(row.getAmount()).isEqualByComparingTo("66.70");
assertThat(row.getWalletStatus()).isEqualTo("FROZEN");
verify(repository, never()).updateFee(any()); verify(repository, never()).updateFunding(any());
verifyNoInteractions(sources, wallet, outbox);
}
/** 换标履约入口登记派送费时使用真实申请身份,同时保留履约订单及收费内容。 */
@Test void relabelDeliveryCreationUsesApplicationAndReplaysSingleFreeze() throws Exception {
var request = relabelDeliveryRequest();
when(businesses.orderType(91L, "10001")).thenReturn("RELABEL_APPLICATION");
when(businesses.owned("LASTLEG_ORDER", 91L, "10001", "TB202610220001")).thenReturn(1);
when(businesses.relabelForOrder(91L, "10001")).thenReturn(
new BusinessOption(501L, "RELABEL_ORDER", "TB202610220001", "10001", "客户甲", 8L, 91L, "RELABEL_APPLICATION"));
fee.setBusinessType("RELABEL_ORDER"); fee.setBusinessId(501L);
fee.setFeeItemName("派送费"); fee.setAmount(new BigDecimal("66.70"));
fee.setOriginalAmount(new BigDecimal("66.70"));
when(sources.create(eq("operator"), eq("relabel-delivery"), any())).thenReturn(fee);
when(repository.lockFunding(701L)).thenReturn(null, funding);
service.create("operator", "relabel-delivery", request);
service.create("operator", "relabel-delivery", request);
var captured = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
verify(sources, times(2)).create(eq("operator"), eq("relabel-delivery"), captured.capture());
var saved = captured.getValue();
assertThat(saved.sourceType()).isEqualTo("OVERSEAS_RELABEL_FEE");
assertThat(saved.businessType()).isEqualTo("RELABEL_ORDER"); assertThat(saved.businessId()).isEqualTo(501L);
assertThat(saved.orderId()).isEqualTo(91L); assertThat(saved.businessNo()).isEqualTo("TB202610220001");
assertThat(saved.feeItemName()).isEqualTo("派送费"); assertThat(saved.amount()).isEqualByComparingTo("66.70");
verify(wallet, times(1)).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), eq(new BigDecimal("66.70")), anyString(), eq(1L));
}
/** 错误单号或缺失真实换标申请时,不能创建费用或发起资金指令。 */
@Test void relabelDeliveryRejectsMismatchedOrMissingBusiness() throws Exception {
var request = relabelDeliveryRequest();
when(businesses.orderType(91L, "10001")).thenReturn("RELABEL_APPLICATION");
assertThatThrownBy(() -> service.create("operator", "bad", request)).hasMessageContaining("不一致");
when(businesses.owned("LASTLEG_ORDER", 91L, "10001", "TB202610220001")).thenReturn(1);
assertThatThrownBy(() -> service.create("operator", "missing", request)).hasMessageContaining("真实换标申请");
verifyNoInteractions(sources, wallet, outbox);
}
/** 复用完整费用请求快照,仅替换本场景业务与派送项目。 */
private com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest relabelDeliveryRequest() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
com.fasterxml.jackson.databind.node.ObjectNode data = mapper.valueToTree(
OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"));
data.put("sourceType", "OVERSEAS_ORDER_FEE"); data.put("businessType", "LASTLEG_ORDER");
data.put("businessId", 91L); data.put("businessNo", "TB202610220001"); data.put("orderId", 91L);
data.put("feeItemCode", "FEE_TYPE_075"); data.put("feeItemName", "派送费"); data.put("amount", new BigDecimal("66.70"));
return mapper.treeToValue(data, com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
}
/** 履约详情只读取同客户真实申请的费用,客户不匹配时不得继续读取。 */
@Test void relabelFulfillmentReadsOnlyItsOwnedApplicationFees() {
when(businesses.relabelForOrder(91L, "10001")).thenReturn(
new BusinessOption(501L, "RELABEL_ORDER", "TB-01", "10001", "客户甲", 8L, 91L, "RELABEL_APPLICATION"));
when(sources.listForBusiness("10001", "RELABEL_ORDER", 501L)).thenReturn(List.of(fee));
assertThat(service.relabelFeesForOrder("10001", 91L)).containsExactly(fee);
assertThat(service.relabelFeesForOrder("10002", 91L)).isEmpty();
verify(sources, never()).listForBusiness(eq("10002"), anyString(), anyLong());
}
/** 列表与详情统一展示主档名称,仅修改查询对象,不改客户归属、费用快照或资金。 */
@Test void displaysCurrentCustomerNameWithoutRewritingFeeOrFunding() {
fee.setCustomerName("旧客户-91");
when(customerNames.resolve(anyCollection())).thenReturn(java.util.Map.of("10001", "客户-66"));
when(repository.page(anyMap())).thenReturn(List.of(fee));
when(repository.count(anyMap())).thenReturn(1L);
assertThat(service.page(1, 20, null, null, null, null, null, null, null, null).records().get(0).getCustomerName())
.isEqualTo("客户-66");
fee.setCustomerName("旧客户-91");
assertThat(service.detail(701L).getCustomerName()).isEqualTo("客户-66");
assertThat(fee.getCustomerId()).isEqualTo("10001");
verify(repository, never()).updateFee(any());
verify(repository, never()).updateFunding(any());
verifyNoInteractions(wallet, outbox);
}
/** 客户筛选及业务候选统一主档名称,不继续展示历史名称。 */
@Test void fillsCustomerAndBusinessOptions() {
when(customerNames.resolve(anyCollection())).thenReturn(java.util.Map.of("10001", "柏悦客户"));
when(businesses.customers()).thenReturn(List.of(new ContainerCustomerOption("10001", "旧客户-91")));
when(businesses.options("WAREHOUSE_TRANSFER", "10001", null)).thenReturn(List.of(
new BusinessOption(91L, "WAREHOUSE_TRANSFER", "TR-001", "10001", "旧客户-91", null, null, null)));
assertThat(service.customers()).containsExactly(new ContainerCustomerOption("10001", "柏悦客户"));
var option = service.businessOptions("WAREHOUSE_TRANSFER", "10001", null).get(0);
assertThat(option.customerName()).isEqualTo("柏悦客户");
assertThat(option.businessId()).isEqualTo(91L);
}
@BeforeEach void setup() {
fee = new OverseasFeeSource(); fee.setId(100L); fee.setSourceFeeId(701L); fee.setSourceVersion(1);
fee.setCustomerId("10001"); fee.setBusinessType("WAREHOUSE_TRANSFER"); fee.setBusinessId(91L);
fee.setBusinessNo("TR-001"); fee.setFeeItemName("出库费"); fee.setDirection("RECEIVABLE");
fee.setReviewStatus("PENDING"); fee.setSourceStatus("ACTIVE"); fee.setPublicationStatus("UNPUBLISHED");
fee.setAmount(new BigDecimal("88.00")); fee.setCreatedBy("operator"); fee.setUpdatedBy("operator");
fee.setCurrency("CNY"); fee.setOriginalCurrency("CNY"); fee.setOriginalAmount(new BigDecimal("88.00"));
fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth("2026-10");
fee.setBusinessDate(java.time.LocalDate.of(2026, 10, 22));
funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCustomerId("10001");
funding.setCollectionMode("WALLET"); funding.setWalletStatus("FROZEN"); funding.setCommandVersion(1L);
funding.setWalletBusinessNo("701-1"); funding.setCommandEventId("freeze-event"); funding.setCommandHistory(new LinkedHashMap<>());
funding.setWalletHoldId("9001");
funding.getCommandHistory().put("freeze-event", "FREEZE");
when(repository.lockFee(701L)).thenReturn(fee); when(repository.lockFunding(701L)).thenReturn(funding);
when(repository.current(701L)).thenReturn(fee); when(repository.funding(anyCollection())).thenReturn(List.of(funding));
when(wallet.publish(anyString(), eq(10001L), anyString(), anyString(), any(), anyString(), anyLong()))
.thenAnswer(invocation -> invocation.getArgument(0).toString().toLowerCase() + "-event");
}
/** 过期详情的审核必须在发出任何扣款指令之前拒绝。 */
@Test void reviewRejectsStaleVersionBeforeFundingMutation() {
assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "stale-review", 2)))
.hasMessageContaining("版本");
assertThat(fee.getReviewStatus()).isEqualTo("PENDING");
assertThat(funding.getWalletStatus()).isEqualTo("FROZEN");
verifyNoInteractions(wallet, outbox);
}
@Test void approvalWaitsForActualWalletSettlementBeforePublication() {
var result = service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1"));
assertThat(result.getReviewStatus()).isEqualTo("APPROVED");
assertThat(result.getPublicationStatus()).isEqualTo("UNPUBLISHED");
assertThat(result.getWalletStatus()).isEqualTo("SETTLE_PENDING");
assertThat(result.getWalletSettlement()).isNull(); verifyNoInteractions(outbox);
service.handle(completed("settle-event", "SETTLE", "SUCCEEDED", "SETTLED", "10001", "701-1", "88.00"));
assertThat(fee.getWalletSettlement().ledgerId()).isEqualTo("9002");
assertThat(fee.getPublicationStatus()).isEqualTo("PUBLISHED");
assertThat(funding.getWalletStatus()).isEqualTo("SETTLED"); verifyNoInteractions(outbox);
}
@Test void legacyBillCannotReplayAnApprovalForAnotherFee() {
fee.setBusinessType("LASTLEG_ORDER");
when(businesses.orderType(91L, "10001")).thenReturn("FIRST_LEG");
when(repository.lockFunding(701L)).thenReturn(null);
var prior = new OverseasFeeCommand(); prior.setSourceFeeId(702L);
when(repository.command("reviewer", "REVIEW", "legacy-review")).thenReturn(prior);
assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "legacy-review")))
.hasMessageContaining("其他费用");
verifyNoInteractions(sources, wallet, outbox);
}
@Test void financeFailedCallbackWithoutHoldIdCanBeRetried() {
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1"));
service.handle(new WalletCommandCompletedV1("settle-event", "SETTLE", 10001L,
"OVERSEAS_MANUAL_FEE", "701-1", "FAILED", null, null, "FIN-WALLET-409", "稍后重试",
Instant.parse("2026-10-22T00:00:00Z"), null, null));
assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_FAILED");
assertThat(funding.getWalletHoldId()).isEqualTo("9001");
service.retry(701L, "operator", "retry-1");
assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_PENDING");
verifyNoInteractions(outbox);
}
@Test void settledResultReplayDoesNotPublishTwice() {
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1"));
var result = completed("settle-event", "SETTLE", "SUCCEEDED", "SETTLED", "10001", "701-1", "88.00");
service.handle(result); service.handle(result);
verifyNoInteractions(outbox);
}
@Test void failedSettlementKeepsUncollectedStateAndAllowsExplicitRetry() {
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1"));
service.handle(completed("settle-event", "SETTLE", "FAILED", "FROZEN", "10001", "701-1", "88.00"));
assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_FAILED"); assertThat(fee.getWalletSettlement()).isNull();
assertThat(service.detail(701L).getAllowedActions()).contains("RETRY_SETTLE").doesNotContain("APPROVE", "VOID", "ADJUST");
service.retry(701L, "operator", "retry-1");
verify(wallet).publish(eq("SETTLE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"), isNull(), anyString(), eq(3L));
verifyNoInteractions(outbox);
}
@Test void rejectionDuringPendingFreezeReleasesAfterConfirmedFreeze() {
funding.setWalletStatus("FREEZE_PENDING");
service.review(701L, "reviewer", new ReviewRequest("REJECT", "金额有误", "reject-1"));
verifyNoInteractions(wallet);
service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "88.00"));
assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING");
verify(wallet).publish(eq("RELEASE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"), isNull(), anyString(), eq(2L));
verifyNoInteractions(outbox);
}
@Test void cancelledPendingFreezeIsReleasedWithoutPublishingFee() {
funding.setWalletStatus("FREEZE_PENDING");
service.cancel(701L, "operator", "cancel-1");
assertThat(fee.getSourceStatus()).isEqualTo("VOIDED");
service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "88.00"));
assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING"); verifyNoInteractions(outbox);
}
@Test void insufficientFundsCannotBeApprovedButCanBeRejectedOrCancelled() {
funding.setWalletStatus("FREEZE_FAILED");
assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "approve")))
.hasMessageContaining("资金尚未锁定");
assertThat(service.detail(701L).getAllowedActions()).contains("REJECT", "RETRY_FREEZE").doesNotContain("APPROVE", "CANCEL");
service.review(701L, "reviewer", new ReviewRequest("REJECT", "金额录错", "reject"));
assertThat(fee.getReviewStatus()).isEqualTo("REJECTED"); verifyNoInteractions(wallet, outbox);
}
@Test void foreignCustomerMismatchedCommandAndAmountAreRejected() {
funding.setWalletStatus("FREEZE_PENDING");
assertThatThrownBy(() -> service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10002", "701-1", "88.00")))
.hasMessageContaining("客户不匹配");
assertThatThrownBy(() -> service.handle(completed("wrong-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "88.00")))
.hasMessageContaining("指令不匹配");
assertThatThrownBy(() -> service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "99.00")))
.hasMessageContaining("金额或冻结引用不匹配");
verifyNoInteractions(wallet, outbox);
}
/** 驳回后无论是否释放完毕,都不能重提、取消或覆盖审核人和原因。 */
@Test void rejectedFeeIsTerminalAndPreservesOriginalReview() {
fee.setReviewStatus("REJECTED"); funding.setWalletStatus("RELEASED"); funding.setCommandVersion(3L);
fee.setReviewReason("金额录错"); fee.setReviewerId("original-reviewer");
fee.setReviewedAt(java.time.LocalDateTime.of(2026, 10, 23, 10, 0));
assertThatThrownBy(() -> service.submit(701L, "operator", "submit-2")).hasMessageContaining("不能提交");
assertThatThrownBy(() -> service.cancel(701L, "operator", "cancel-2")).hasMessageContaining("已驳回");
assertThat(service.detail(701L).getAllowedActions()).isEmpty();
assertThat(fee.getReviewStatus()).isEqualTo("REJECTED");
assertThat(fee.getReviewReason()).isEqualTo("金额录错"); assertThat(fee.getReviewerId()).isEqualTo("original-reviewer");
assertThat(fee.getReviewedAt()).isEqualTo(java.time.LocalDateTime.of(2026, 10, 23, 10, 0));
verifyNoInteractions(wallet, outbox); verify(repository, never()).updateFee(any());
}
/** 已冻结费用驳回后最终释放余额和授信占用,重复回执不再发任何扣款。 */
@Test void rejectedFrozenFeeCompletesReleaseAndCanNeverBeApprovedAgain() {
service.review(701L, "reviewer", "审核员", new ReviewRequest("REJECT", "金额录错", "reject-frozen"));
assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING");
var result = completed("release-event", "RELEASE", "SUCCEEDED", "RELEASED", "10001", "701-1", "88.00");
service.handle(result); service.handle(result);
assertThat(funding.getWalletStatus()).isEqualTo("RELEASED");
assertThat(fee.getReviewReason()).isEqualTo("金额录错"); assertThat(fee.getReviewerName()).isEqualTo("审核员");
assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "approve-after-reject"))).hasMessageContaining("待审核");
verify(wallet, times(1)).publish(eq("RELEASE"), anyLong(), anyString(), eq("701-1"), isNull(), anyString(), eq(2L));
verify(wallet, never()).publish(eq("SETTLE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong());
}
/** 资金释放失败仍可走补偿,补偿不会重开原费用或改变原审核记录。 */
@Test void rejectedReleaseFailureCanRecoverWithoutReopeningFee() {
service.review(701L, "reviewer", new ReviewRequest("REJECT", "金额录错", "reject-release"));
service.handle(completed("release-event", "RELEASE", "FAILED", "FROZEN", "10001", "701-1", "88.00"));
assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_FAILED");
assertThat(service.detail(701L).getAllowedActions()).containsExactly("RETRY_RELEASE");
when(wallet.publish(eq("RELEASE"), anyLong(), anyString(), anyString(), any(), anyString(), eq(3L))).thenReturn("release-retry");
service.retry(701L, "operator", "release-retry-key");
service.handle(completed("release-retry", "RELEASE", "SUCCEEDED", "RELEASED", "10001", "701-1", "88.00"));
assertThat(fee.getReviewStatus()).isEqualTo("REJECTED"); assertThat(fee.getReviewReason()).isEqualTo("金额录错");
assertThat(funding.getWalletStatus()).isEqualTo("RELEASED");
assertThat(service.detail(701L).getAllowedActions()).isEmpty();
}
/** 新建同项目时先解释用途,校验发生在任何资金冻结之前。 */
@Test void duplicateProjectRequiresRemarkButAllowsAnotherRealService() throws Exception {
var request = relabelDeliveryRequest();
when(sources.create(eq("operator"), anyString(), any())).thenReturn(fee);
when(repository.lockFunding(701L)).thenReturn(null);
when(repository.hasSameProject(fee)).thenReturn(true);
assertThatThrownBy(() -> service.create("operator", "duplicate-no-remark", request)).hasMessageContaining("费用说明");
verifyNoInteractions(wallet, outbox); verify(repository, never()).insertFunding(any());
fee.setFeeRemark("第二次预约补收");
var created = service.create("operator", "duplicate-with-remark", request);
assertThat(created.getFeeRemark()).isEqualTo("第二次预约补收");
verify(wallet, times(1)).publish(eq("FREEZE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong());
}
@Test void firstLegAdditionalFeeApprovalPublishesBillWithoutWalletCommand() {
funding.setCollectionMode("BILL"); funding.setWalletStatus("NOT_REQUIRED");
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "head-leg-approve"));
assertThat(fee.getPublicationStatus()).isEqualTo("PUBLISHED"); assertThat(fee.getWalletSettlement()).isNull();
verify(outbox).publish(fee, "PUBLISHED"); verifyNoInteractions(wallet);
}
/** 尾程各业务供应商应付统一进入账单结算,不冻结或扣客户余额。 */
@Test void supplierPayablesAlwaysInitializeBillAndPublishAfterReview() {
fee.setDirection("PAYABLE"); fee.setBusinessType("LASTLEG_ORDER"); fee.setReviewStatus("DRAFT");
when(repository.lockFunding(701L)).thenReturn(null);
var submitted = service.submit(701L, "operator", "payable-submit");
assertThat(submitted.getCollectionMode()).isEqualTo("BILL"); assertThat(submitted.getWalletStatus()).isEqualTo("NOT_REQUIRED");
var captured = ArgumentCaptor.forClass(OverseasFeeFunding.class); verify(repository).insertFunding(captured.capture());
when(repository.lockFunding(701L)).thenReturn(captured.getValue());
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "payable-approve"));
verify(outbox).publish(fee, "PUBLISHED"); verifyNoInteractions(wallet);
}
@Test void manualCreateReservesAfterCreatingTheSingleFeeSource() {
var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567");
when(sources.create("operator", "create-1", request)).thenReturn(fee); when(repository.lockFunding(701L)).thenReturn(null);
var result = service.create("operator", "create-1", request);
assertThat(result.getReviewStatus()).isEqualTo("PENDING"); assertThat(result.getWalletStatus()).isEqualTo("FREEZE_PENDING");
verify(catalog).requireActive(request.feeItemCode(), request.feeItemName(), "RECEIVABLE");
ArgumentCaptor<OverseasFeeFunding> captured = ArgumentCaptor.forClass(OverseasFeeFunding.class);
verify(repository).insertFunding(captured.capture()); assertThat(captured.getValue().getSourceFeeId()).isEqualTo(701L);
verifyNoInteractions(outbox);
}
/** 海外业务尾程单不能因统一的 LASTLEG_ORDER 类型误走头程账单。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"WAREHOUSE_TRANSFER", "RETURN_RELABEL", "ONE_CLICK_DROPSHIP"})
void localTailOrderFeesReserveWallet(String orderType) {
fee.setBusinessType("LASTLEG_ORDER");
when(businesses.orderType(91L, "10001")).thenReturn(orderType);
when(repository.lockFunding(701L)).thenReturn(null);
var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567");
when(sources.create("operator", "local-fee", request)).thenReturn(fee);
var result = service.create("operator", "local-fee", request);
assertThat(result.getCollectionMode()).isEqualTo("WALLET");
assertThat(result.getWalletStatus()).isEqualTo("FREEZE_PENDING");
verify(wallet).publish(eq("FREEZE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), anyString(),
eq(new BigDecimal("88.00")), anyString(), eq(1L));
verifyNoInteractions(outbox);
}
/** 本土卡车与散板应收沿钱包冻结、审核实扣,不转入账单。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"US_TRUCK", "LOCAL_LOOSEBOARD"})
void localTransportReceivablesReserveWallet(String businessType) {
fee.setBusinessType(businessType); fee.setReviewStatus("DRAFT");
when(repository.lockFunding(701L)).thenReturn(null);
var result = service.submit(701L, "operator", "transport-submit");
assertThat(result.getCollectionMode()).isEqualTo("WALLET");
assertThat(result.getWalletStatus()).isEqualTo("FREEZE_PENDING");
var captured = ArgumentCaptor.forClass(OverseasFeeFunding.class);
verify(repository).insertFunding(captured.capture());
when(repository.lockFunding(701L)).thenReturn(captured.getValue());
captured.getValue().setWalletStatus("FROZEN");
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "transport-approve"));
verify(wallet).publish(eq("SETTLE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), anyString(),
isNull(), anyString(), eq(2L));
verifyNoInteractions(outbox);
}
/** 自动仓租项目不能通过人工入口重复建应收或锁款。 */
@Test void automaticRentItemCannotBeCreatedManually() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
com.fasterxml.jackson.databind.node.ObjectNode data = mapper.valueToTree(
OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"));
data.put("feeItemCode", "FEE_TYPE_077"); data.put("feeItemName", "仓储费");
var request = mapper.treeToValue(data, com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
assertThatThrownBy(() -> service.create("operator", "manual-rent", request))
.hasMessageContaining("仓租自动计算");
verifyNoInteractions(sources, repository, catalog, wallet, outbox);
}
@Test void createReplayDoesNotSendSecondFreezeCommand() {
var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567");
when(sources.create("operator", "create-1", request)).thenReturn(fee);
service.create("operator", "create-1", request); verifyNoInteractions(wallet, outbox);
}
@Test void automaticSettledStorageFeeCannotBeCancelledAdjustedOrVoided() {
fee.setWalletSettlement(new com.baiyue.common.event.WalletSettlementReference("e", "h", "l"));
assertThat(service.detail(701L).getAllowedActions()).isEmpty();
assertThatThrownBy(() -> service.cancel(701L, "operator", "cancel")).hasMessageContaining("不能取消");
}
/** 原币USD保留,钱包必须按已固化人民币快照冻结和核对回执。 */
@Test void usdFeeFreezesConvertedCnyAndRejectsOriginalCurrencyAmount() {
fee.setOriginalCurrency("USD"); fee.setOriginalAmount(new BigDecimal("10.00"));
fee.setExchangeRate(new BigDecimal("7.200000")); fee.setAmount(new BigDecimal("72.00"));
fee.setReviewStatus("DRAFT"); funding.setWalletStatus("FREEZE_FAILED");
service.submit(701L, "operator", "usd-submit");
verify(wallet).publish(eq("FREEZE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-2"),
eq(new BigDecimal("72.00")), anyString(), eq(2L));
assertThatThrownBy(() -> service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-2", "10.00")))
.hasMessageContaining("金额或冻结引用不匹配");
service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-2", "72.00"));
assertThat(funding.getWalletStatus()).isEqualTo("FROZEN");
}
/** 历史海外待审费用不得继续沿原审核入口直接发布账单。 */
@Test void oldOverseasPendingFeeMustBeSubmittedToReserveBeforeReview() {
when(repository.lockFunding(701L)).thenReturn(null);
assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "legacy-review")))
.hasMessageContaining("重新提交");
service.submit(701L, "operator", "legacy-submit");
verify(repository).insertFunding(any()); verify(wallet).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(),
eq(new BigDecimal("88.00")), anyString(), eq(1L)); verifyNoInteractions(outbox);
}
/** 一件代发与头程共用订单表,结算方式按当前业务类型分别确定。 */
@Test void dropshipOrderUsesWalletWhereFirstLegUsesBill() {
fee.setBusinessType("LASTLEG_ORDER"); when(businesses.orderType(91L, "10001")).thenReturn("ONE_CLICK_DROPSHIP");
when(repository.lockFunding(701L)).thenReturn(null);
service.submit(701L, "operator", "dropship-submit"); verify(wallet).publish(eq("FREEZE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong());
}
/** 回执必须携带完整成功事实,不能用其他冻结记录替换本次锁款。 */
@Test void invalidResultAndForeignHoldCannotSettleFee() {
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "approve"));
var otherHold = new WalletCommandCompletedV1("settle-event", "SETTLE", 10001L, "OVERSEAS_MANUAL_FEE", "701-1",
"SUCCEEDED", "SETTLED", new BigDecimal("88.00"), null, null, Instant.now(), "other", "ledger");
assertThatThrownBy(() -> service.handle(otherHold)).hasMessageContaining("原锁款不匹配");
var invalid = new WalletCommandCompletedV1("settle-event", "SETTLE", 10001L, "OVERSEAS_MANUAL_FEE", "701-1",
"UNKNOWN", null, null, null, null, null, null, null);
assertThatThrownBy(() -> service.handle(invalid)).hasMessageContaining("字段不完整");
verifyNoInteractions(outbox);
}
/** 头程附加费建单不冻结,审核后才进入头程账单。 */
@Test void firstLegCreationUsesBillAndRequiresApprovalBeforePublication() {
fee.setBusinessType("LASTLEG_ORDER"); when(businesses.orderType(91L, "10001")).thenReturn("FIRST_LEG");
when(repository.lockFunding(701L)).thenReturn(null);
var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567");
when(sources.create("operator", "firstleg-create", request)).thenReturn(fee);
var created = service.create("operator", "firstleg-create", request);
assertThat(created.getCollectionMode()).isEqualTo("BILL"); assertThat(created.getReviewStatus()).isEqualTo("PENDING");
verifyNoInteractions(wallet, outbox);
}
/** 旧失败回执在重试后重放不覆盖新扣款,成功仍只入池一次。 */
@Test void staleFailureReplayCannotOverwriteRetrySettlement() {
when(wallet.publish(eq("SETTLE"), anyLong(), anyString(), anyString(), any(), anyString(), eq(3L))).thenReturn("settle-retry");
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "retry-review"));
var oldFailure = completed("settle-event", "SETTLE", "FAILED", "FROZEN", "10001", "701-1", "88.00");
service.handle(oldFailure); service.retry(701L, "operator", "retry-key"); service.handle(oldFailure);
assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_PENDING");
service.handle(completed("settle-retry", "SETTLE", "SUCCEEDED", "SETTLED", "10001", "701-1", "88.00"));
assertThat(funding.getWalletStatus()).isEqualTo("SETTLED"); verifyNoInteractions(outbox);
}
/** 采购费用沿原采购锁款结算,禁止由旧通用提交入口再扣一次。 */
@Test void procurementCannotEnterGenericFundingEvenWhenLegacy() {
fee.setBusinessType("PROCUREMENT_FEE"); when(repository.lockFunding(701L)).thenReturn(null);
assertThatThrownBy(() -> service.submit(701L, "operator", "procurement-submit")).hasMessageContaining("采购费用");
assertThat(service.detail(701L).getAllowedActions()).isEmpty(); verifyNoInteractions(wallet, outbox);
}
@Test void oldRelabelHoldBlocksEquivalentCatalogItemButNotAnotherService() {
fee.setBusinessType("RELABEL_ORDER"); fee.setFeeItemCode("FEE_TYPE_071"); fee.setFeeItemName("贴标费");
fee.setReviewStatus("DRAFT"); when(repository.lockFunding(701L)).thenReturn(null);
when(repository.legacyRelabel("10001","RELABEL_ORDER",91L)).thenReturn(1);
assertThatThrownBy(() -> service.submit(701L,"operator","legacy-label")).hasMessageContaining("旧版收费");
verifyNoInteractions(wallet,outbox);
fee.setFeeItemCode("FEE_TYPE_075"); fee.setFeeItemName("派送费");
service.submit(701L,"operator","different-service");
verify(wallet).publish(eq("FREEZE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
@Test void oldAutomaticReceivableBlocksSameServiceBeforeNewHold() {
fee.setReviewStatus("DRAFT"); when(repository.lockFunding(701L)).thenReturn(null);
when(repository.legacyProject(anyString(),anyString(),anyLong(),any(),any(),anyLong())).thenReturn(1);
assertThatThrownBy(() -> service.submit(701L,"operator","legacy-project")).hasMessageContaining("旧版收费");
verifyNoInteractions(wallet,outbox);
}
private WalletCommandCompletedV1 completed(String event, String operation, String result, String holdStatus,
String customer, String businessNo, String amount) {
return new WalletCommandCompletedV1(event, operation, Long.parseLong(customer), "OVERSEAS_MANUAL_FEE", businessNo,
result, holdStatus, new BigDecimal(amount), "FAILED".equals(result) ? "INSUFFICIENT" : null,
"FAILED".equals(result) ? "余额不足" : null, Instant.parse("2026-10-22T00:00:00Z"), "9001", "9002");
}
}
@@ -16,6 +16,24 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementSettledFeeSourceTest {
/** 本土运输不得通过应付来源绕开客户余额和授信扣费。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"US_TRUCK,OVERSEAS_TRUCK_FEE", "LOCAL_LOOSEBOARD,OVERSEAS_LOOSEBOARD_FEE"})
void rejectsLocalTransportPayablesBeforeWriting(String businessType, String sourceType) {
var mapper = mock(OverseasFeeSourceMapper.class);
var outbox = mock(OverseasFeeOutboxService.class);
var converter = mock(OverseasFeeCurrencyConverter.class);
var guard = mock(OverseasFeeBusinessGuard.class);
var service = new OverseasFeeSourceService(mapper, mock(OverseasFeeCommandMapper.class), guard,
outbox, converter, new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class));
var request = new SaveRequest("9", "客户", sourceType, businessType, 11L, "LOCAL-1",
null, null, null, null, null, null, null, "FREIGHT", "运费", null, "PAYABLE",
71L, "供应商", "TRUCKING", "ON_ACCOUNT", null, null, null, List.of(),
LocalDate.of(2026, 10, 23), "CNY", new BigDecimal("88.00"), null, null, null, null, null);
assertThatThrownBy(() -> service.create("7", "local-payable", request)).hasMessageContaining("只能登记应收");
verifyNoInteractions(mapper, outbox, converter, guard);
}
@Test
void publishesActualFeeUsingConfirmedFxAndConsumptionReference() {
var mapper = mock(OverseasFeeSourceMapper.class);
@@ -25,7 +43,7 @@ class ProcurementSettledFeeSourceTest {
mock(OverseasFeeBusinessGuard.class), outbox, converter, new ObjectMapper().findAndRegisterModules(),
mock(OverseasFeeCancellation.class));
SaveRequest fee = new SaveRequest("9", "customer", "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", 11L,
"PF-1", 12L, null, null, null, null, null, null, "SAMPLE_PAYMENT", "样品货款", "RECEIVABLE",
"PF-1", 12L, null, null, null, null, null, null, "SAMPLE_PAYMENT", "样品货款", null, "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 10, 9), "USD",
new BigDecimal("10.00"), BigDecimal.ONE, "ITEM", new BigDecimal("10.00"), null, null);
assertThatThrownBy(() -> service.create("7", "manual-source", fee)).hasMessageContaining("采购费用必须通过");
@@ -39,7 +57,7 @@ class ProcurementSettledFeeSourceTest {
assertThat(result.getWalletSettlement()).isEqualTo(reference);
assertThat(result.getReviewStatus()).isEqualTo("APPROVED");
assertThat(result.getPublicationStatus()).isEqualTo("PUBLISHED");
verify(outbox).publish(result, "PUBLISHED");
verifyNoInteractions(outbox);
verify(mapper).insert(result);
verifyNoInteractions(converter);
assertThatThrownBy(() -> service.publishSettled("7", "source-2", fee, conversion, null))
@@ -59,6 +59,7 @@ class InboundStockReceiptServiceTest {
@BeforeEach
void setUp() {
service = new InboundStockReceiptService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts);
@@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DestuffingPalletCostServiceTest {
@Test void publishesDestuffingReceivableAsOverseasContainerBusiness() {
@Test void retainsCostClassificationForManualCustomerCharging() {
assertThat(DestuffingPalletCostService.DESTUFF_BUSINESS_TYPE).isEqualTo("OVERSEAS_CONTAINER");
}
@Test void newestCompletedBookingsAreNotStarvedByOldUnbillableRows() {
@@ -20,6 +20,27 @@ class DestuffingPalletCostServiceTest {
.contains("pp.actual_package_count>0")
.contains("pp.actual_volume_cbm>0");
}
/** 真实完成的成本仅写分摊依据,不产生客户应收关联。 */
@Test void completedInboundRecordsCostAllocationWithoutFeeSource() {
var jdbc=org.mockito.Mockito.mock(org.springframework.jdbc.core.JdbcTemplate.class);
var pallets=org.mockito.Mockito.mock(com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.class);
var service=new DestuffingPalletCostService(jdbc,pallets);
org.mockito.Mockito.when(jdbc.queryForObject(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.eq(Integer.class),org.mockito.ArgumentMatchers.eq("77"))).thenReturn(0);
org.mockito.Mockito.when(jdbc.queryForList(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.eq("77"))).thenReturn(List.of(java.util.Map.<String,Object>of("status","COMPLETED","container_no","CONT-77","overseas_warehouse_id",8L)));
org.mockito.Mockito.when(jdbc.queryForList(DestuffingPalletCostService.RECEIVED_PALLET_IDS_SQL,Long.class,"77")).thenReturn(List.of(1L));
org.mockito.Mockito.when(pallets.resolve(1L)).thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(1,"P-1",8,null,"AVAILABLE",null,BigDecimal.TEN,1));
org.mockito.Mockito.when(jdbc.query(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.<org.springframework.jdbc.core.RowMapper<Object>>any(),org.mockito.ArgumentMatchers.eq("77")))
.thenAnswer(invocation -> {
var rs=org.mockito.Mockito.mock(java.sql.ResultSet.class);
org.mockito.Mockito.when(rs.getString("customer_id")).thenReturn("10001");
org.mockito.Mockito.when(rs.getBigDecimal("actual_volume")).thenReturn(BigDecimal.ONE);
return List.of(((org.springframework.jdbc.core.RowMapper<?>)invocation.getArgument(1)).mapRow(rs,0));
});
service.generateIfReady("77","operator");
org.mockito.Mockito.verify(jdbc).update(org.mockito.ArgumentMatchers.contains("VALUES(?,?,?,?,?,NULL,NOW(3))"),
org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.eq("10001"),org.mockito.ArgumentMatchers.eq(BigDecimal.ONE),org.mockito.ArgumentMatchers.eq(new BigDecimal("10.00")));
}
@Test void preservesTotalAndLetsFinalCustomerAbsorbRoundingRemainder() {
List<BigDecimal> result = DestuffingPalletCostService.allocate(new BigDecimal("10.00"),
List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1")));
@@ -43,6 +43,7 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementStatusHistoryMapper histories;
@Mock ProcurementWarehouseDirectory warehouseDirectory;
@Mock com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
@Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
@Mock OverseasProcurementFileMapper procurementFiles;
@Mock OverseasProcurementFeeMapper procurementFees;
@@ -57,6 +58,7 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
ReflectionTestUtils.setField(service, "directionRepository", directionRepository);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
@@ -190,4 +192,78 @@ class OverseasProcurementServiceTest {
assertEquals("label.pdf",service.downloadFile("100","HC1",42).fileName());
verify(fileGateway).download("100",1,"LABEL",42);
}
@Test void exportReviewPersistsDirectionAndCannotReplayWithChangedDirection() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
order.setProcurementDirection(ProcurementDirection.EXPORT);
when(orders.lockNo("100","HC1")).thenReturn(order);
when(orders.findNo("100","HC1")).thenReturn(order);
when(histories.listOrder("100",1L)).thenReturn(List.of());
when(warehouseDirectory.required(10L,ProcurementDirection.EXPORT)).thenReturn(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(10L,"SZX","国内采购仓","CN","ACTIVE"));
var request = new OverseasProcurementRequests.Review(0L,"APPROVE","export-review","已核对海外收件资料",10L,ProcurementDirection.EXPORT);
var result = service.review("100","HC1","7",request);
assertEquals(ProcurementDirection.EXPORT,result.summary().procurementDirection());
assertEquals("国内采购仓",result.summary().purchaseWarehouseName());
assertEquals(1L,service.review("100","HC1","7",request).summary().version());
assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"APPROVE","export-review","已核对海外收件资料",10L,ProcurementDirection.IMPORT)));
verify(warehouseDirectory,never()).required(10L);
}
@Test void oldRequestHashDoesNotGainNullDirectionField() throws Exception {
var request = new OverseasProcurementRequests.Review(0L,"APPROVE","old-key","checked",10L);
var json = new ObjectMapper().writeValueAsString(request);
assertEquals(false,json.contains("procurementDirection"));
assertEquals(ProcurementDirection.IMPORT,ProcurementDirection.normalize(null));
}
@Test void pendingReviewCannotReverseDirectionOfAnAlreadyRecordedPurchase() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
when(orders.lockNo("100","HC1")).thenReturn(order);
assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"APPROVE","export-after-purchase","checked",10L,ProcurementDirection.EXPORT)));
verify(warehouseDirectory,never()).required(10L,ProcurementDirection.EXPORT);
assertEquals(OverseasProcurementService.PENDING_REVIEW,order.getStatus());
}
@Test void clientCanCreateDomesticPurchaseForAnOverseasRecipient() {
AtomicReference<OverseasProcurementOrder> saved = new AtomicReference<>();
when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; });
when(orders.findNo(anyString(),anyString())).thenAnswer(ignored -> saved.get());
when(histories.listOrder(anyString(),any())).thenReturn(List.of());
var detail=service.create("100","7",new OverseasProcurementRequests.Create("Acme","Office","Sample","A",null,10L,
BigDecimal.TEN,"CNY","US, California, test destination","Buyer","+1 1234567890",null,"export-create",ProcurementDirection.EXPORT));
assertEquals(ProcurementDirection.EXPORT,detail.summary().procurementDirection());
assertEquals("US, California, test destination",detail.receivingAddress());
assertEquals(OverseasProcurementService.PENDING_REVIEW,detail.summary().status());
}
@Test void rejectionRequiresSpecificNonblankReasonWithoutAWarehouse() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
when(orders.lockNo("100","HC1")).thenReturn(order);
for (String reason : new String[]{null,""," "}) {
var error = assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"REJECT","reject-empty",reason,null)));
assertEquals("请填写拒绝原因",error.getReason());
}
assertEquals(OverseasProcurementService.PENDING_REVIEW,order.getStatus());
when(orders.findNo("100","HC1")).thenReturn(order);
when(histories.listOrder("100",1L)).thenReturn(List.of());
var result=service.review("100","HC1","7",new OverseasProcurementRequests.Review(0L,"REJECT","reject-reason","供应商无法供货",null));
assertEquals(OverseasProcurementService.REJECTED,result.summary().status());
assertEquals("供应商无法供货",result.reviewRemark());
verify(warehouseDirectory,never()).required(10L);
}
@Test void reviewCannotChangeDirectionEvenBeforePurchase() {
var order = order(OverseasProcurementService.PENDING_REVIEW,0,0);
order.setProcurementDirection(ProcurementDirection.EXPORT);
when(orders.lockNo("100","HC1")).thenReturn(order);
var error=assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"APPROVE","change-direction","checked",10L,ProcurementDirection.IMPORT)));
assertEquals("审核不能修改采购方向,请按客户下单方向选择采购仓",error.getReason());
assertEquals(ProcurementDirection.EXPORT,order.getProcurementDirection());
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper;
import com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ProcurementDirectionPersistenceTest {
@Test void directionGateReadsOnlyThisOrdersRecordedFacts() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_procurement_flow(order_id BIGINT PRIMARY KEY,purchased_at TIMESTAMP,actual_warehouse_id BIGINT,overseas_shipped_at TIMESTAMP)");
var config = new MybatisConfiguration(); config.addMapper(ProcurementDirectionMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var repository = new ProcurementDirectionRepository(new SqlSessionTemplate(factory.getObject()).getMapper(ProcurementDirectionMapper.class));
assertThat(repository.hasStarted(1)).isFalse();
jdbc.update("INSERT INTO ll_procurement_flow(order_id) VALUES(1)");
assertThat(repository.hasStarted(1)).isFalse();
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=CURRENT_TIMESTAMP WHERE order_id=1");
assertThat(repository.hasStarted(1)).isTrue();
assertThat(repository.hasStarted(2)).isFalse();
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=NULL,actual_warehouse_id=91 WHERE order_id=1");
assertThat(repository.hasStarted(1)).isTrue();
}
}
@@ -81,9 +81,8 @@ class ProcurementFinancePersistenceTest {
var saved=fees.selectById(fee.getId());assertThat(saved.getWalletStatus()).isEqualTo("SETTLED");
assertThat(saved.getSourceFeeId()).isEqualTo(901L);
verify(sources).publishSettled(eq("7"),anyString(),any(),any(),argThat(ref -> ref.commandEventId().equals(settled.commandEventId())));
assertThat(service.sourceLinked(saved)).isFalse();
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
assertThat(service.sourceLinked(saved)).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero();
}
@Test void fullInboundNeverSettlesEstimateAndActualUsesOriginalHold() {
@@ -205,10 +204,8 @@ class ProcurementFinancePersistenceTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2);
verify(sources,times(2)).publishSettled(anyString(),anyString(),any(),any(),argThat(ref->ref.commandEventId().equals(result.commandEventId())));
verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
assertThat(service.sourceLinked(recovered)).isFalse();
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("来源关联");
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
assertThat(service.sourceLinked(recovered)).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero();
service.requireShipmentReady(11);
}
@@ -260,8 +257,7 @@ class ProcurementFinancePersistenceTest {
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100"));
handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100"));
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("来源关联");
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero();
service.requireShipmentReady(11);
tx.execute(s->service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","pending-refund"));
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金");
@@ -65,4 +65,13 @@ class ProcurementQueryServiceTest {
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)");
assertThat(query.customer("9","PO-1").boxes()).isEmpty();
}
@Test void exportProjectionExposesDirectionAndLabelsWithoutRewritingHistoricalLegs() {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=? WHERE id=11",ProcurementDirection.EXPORT);
var result=query.platform("9","PO-1",Set.of("platform:overseas-business:purchase:receive-overseas"));
assertThat(result.customerView().order().procurementDirection()).isEqualTo(ProcurementDirection.EXPORT);
assertThat(result.customerView().progress()).extracting(ProcurementQueryService.Progress::title).contains("国内收货完成","海外收货完成","海外费用核定");
assertThat(result.actions()).filteredOn(a -> a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a -> assertThat(a.label()).isEqualTo("登记国内实收"));
}
}
@@ -30,6 +30,7 @@ import static org.mockito.ArgumentMatchers.*;
class ProcurementTransportPersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;
ProcurementWarehouseDirectory warehouses;
ProcurementTransportService service;ProcurementFinanceService finance;OverseasProcurementFee estimate;
final Address warehouse=new Address("Overseas","1234567890","US operating address");
final Address domestic=new Address("Domestic","13800000000","Shanghai receiving address");
@@ -60,7 +61,7 @@ class ProcurementTransportPersistenceTest {
jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'C1',20,30,40,6.125,6),(202,11,301,'C2',20,30,40,4.125,4)");
jdbc.update("INSERT INTO ll_procurement_carton_source VALUES(201,101,6),(202,101,4)");
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','LABEL',1001,'labels.pdf','application/pdf',10,'7'),(502,11,'9','EXCEPTION',1002,'proof.pdf','application/pdf',10,'7')");
var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse);
warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse);
finance=mock(ProcurementFinanceService.class);var payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED");
when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true);
estimate=new OverseasProcurementFee();estimate.setId(801L);estimate.setAmount(BigDecimal.ZERO);estimate.setWalletStatus("ZERO");
@@ -172,4 +173,32 @@ class ProcurementTransportPersistenceTest {
private void apply(){tx.execute(s->service.applyLabels("9","PO-1","7",new ApplyLabels(version(),"apply-"+version(),labelVersion(Leg.OVERSEAS),List.of(201L,202L),start.plusMinutes(30),"attached")));}
private Ship shipRequest(Leg leg,String key){return new Ship(version(),key,leg,labelVersion(leg),List.of(201L,202L),start.plusMinutes(leg==Leg.OVERSEAS?60:100),"actual handoff");}
private ProcurementTransportService.Snapshot ship(Leg leg,String key){return tx.execute(s->service.ship("9","PO-1","7",shipRequest(leg,key)));}
@Test void exportUsesDomesticSenderAndOverseasDestinationThenEndsAfterLocalHandoff() {
var sender = new Address("Domestic warehouse","13800000000","Shenzhen domestic origin");
var receiver = new Address("Overseas warehouse","+1 1234567890","Los Angeles overseas receiving");
var finalReceiver = new Address("Overseas customer","+1 9876543210","San Diego customer destination");
jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=?,contact_name=?,contact_phone=?,receiving_address=? WHERE id=11",
ProcurementDirection.EXPORT,finalReceiver.name(),finalReceiver.phone(),finalReceiver.address());
when(warehouses.sender(91,ProcurementDirection.EXPORT)).thenReturn(sender);
tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-labels",Leg.OVERSEAS,1,receiver,null,List.of(label(201),label(202)),"export manifest")));
review(BigDecimal.ZERO); apply();
assertThat(ship(Leg.OVERSEAS,"export-international").status()).isEqualTo("INTERNATIONAL_TRANSIT");
String snapshot = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='OVERSEAS'",String.class);
assertThat(snapshot).contains(sender.address(),receiver.address()).doesNotContain(warehouse.address());
jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(102,11,'9','PR-2','DOMESTIC',201,6,0,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65));
jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=? WHERE order_id=11",start.plusMinutes(70));
tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-local-labels",Leg.DOMESTIC,1,finalReceiver,null,List.of(label(201),label(202)),"overseas delivery")));
assertThatThrownBy(() -> ship(Leg.DOMESTIC,"before-charges")).hasMessageContaining("费用尚未确认");
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(80));
var request = shipRequest(Leg.DOMESTIC,"export-local");
assertThat(tx.execute(s -> service.ship("9","PO-1","7",request)).status()).isEqualTo("SHIPPED_TO_CUSTOMER");
tx.execute(s -> service.ship("9","PO-1","7",request));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(2);
String local = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='DOMESTIC'",String.class);
assertThat(local).contains(receiver.address(),finalReceiver.address());
verify(warehouses,never()).sender(91);
verify(finance).requireShipmentReady(11);
}
}
@@ -32,4 +32,25 @@ class ProcurementWarehouseDirectoryTest {
receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(92L,9L,"LAX","Other","OVERSEAS","US","ACTIVE","Receiver","1234567890"),List.of(address))));
assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("暂不可用");
}
@Test void domesticDirectionUsesDomesticCatalogAndChecksMasterScopeTypeAndStatus() {
ReflectionTestUtils.setField(directory,"token","test-internal");
when(client.domesticWarehouses("test-internal")).thenReturn(ApiResponse.success(List.of(new DomesticWarehouse(91L,"SZX","深圳仓"))));
assertThat(directory.options(ProcurementDirection.EXPORT)).singleElement().satisfies(w -> assertThat(w.warehouseId()).isEqualTo(91L));
var domesticAddress = new Address("OPERATING","CN","广东","深圳",null,"518000","测试国内作业地址",null);
when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(
new Warehouse(91L,0L,"SZX","深圳仓","DOMESTIC","CN","ACTIVE","仓管","13800000000"),List.of(domesticAddress))));
assertThat(directory.sender(91,ProcurementDirection.EXPORT).address()).contains("深圳","测试国内作业地址");
verify(client,never()).receivingWarehouse(anyLong(),anyString());
for (var invalid : List.of(
new Warehouse(91L,0L,"LAX","海外仓","OVERSEAS","US","ACTIVE","仓管","13800000000"),
new Warehouse(91L,9L,"SZX","客户仓","DOMESTIC","CN","ACTIVE","仓管","13800000000"),
new Warehouse(91L,0L,"SZX","停用仓","DOMESTIC","CN","INACTIVE","仓管","13800000000"),
new Warehouse(92L,0L,"SZX","其他仓","DOMESTIC","CN","ACTIVE","仓管","13800000000"))) {
when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(invalid,List.of(domesticAddress))));
assertThatThrownBy(() -> directory.required(91,ProcurementDirection.EXPORT)).hasMessageContaining("国内公共仓");
}
assertThatThrownBy(() -> directory.options("INVALID")).hasMessageContaining("方向无效");
}
}
@@ -61,6 +61,7 @@ import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
@@ -315,6 +316,9 @@ class RelabelApplicationServiceTest {
assertEquals("PENDING_REVIEW", relabelHistory.getValue().getToStatus());
assertEquals("PENDING_REVIEW", tailHistory.getValue().getToStatus());
assertEquals("FBR-1", result.application().relabelNo());
assertEquals("NOT_REQUIRED", application.getValue().getWalletStatus());
assertEquals(request.chargeAmount(), application.getValue().getChargeAmount());
verifyNoInteractions(walletCommands);
}
@Test
@@ -831,6 +835,28 @@ class RelabelApplicationServiceTest {
assertNull(app.getCustomerConfirmedAt());
}
@Test
void newQuotedApplicationNeverAutoPublishesOnCompletion() {
var app = confirmationApplication("TRUCK", false); app.setWalletStatus("NOT_REQUIRED");
when(applications.lockNo("10001", "FBR-1")).thenReturn(app);
when(orders.selectById(21L)).thenReturn(order(21L,"10001","LMO-1","WAIT_RELABEL"));
service.settlePdaCompletion("10001","FBR-1","picker");
verifyNoInteractions(walletCommands,feeSources);
}
@Test
void legacySettlementPublishesOnlyAfterVerifiedPaidReferenceAndIgnoresReplay() {
var app = confirmationApplication("TRUCK", false); app.setWalletStatus("SETTLE_PENDING"); app.setWalletCommandEventId("settle-legacy");
when(applications.lockNo("10001","FBR-1")).thenReturn(app);
when(orders.selectById(21L)).thenReturn(order(21L,"10001","LMO-1","WAIT_RELABEL"));
var result = new com.baiyue.common.event.WalletCommandCompletedV1("settle-legacy","SETTLE",10001L,"RELABEL_ORDER","FBR-1",
"SUCCEEDED","SETTLED",app.getChargeAmount(),null,null,java.time.Instant.now(),"hold-1","ledger-1");
service.handle(result); service.handle(result);
verify(feeSources,times(1)).completeLegacyRelabelSettlement(eq("WALLET_CALLBACK"),eq("RELABEL:FBR-1"),any(),
eq(new com.baiyue.common.event.WalletSettlementReference("settle-legacy","hold-1","ledger-1")));
assertEquals("SETTLED",app.getWalletStatus());
}
@Test
void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
@@ -1133,16 +1159,11 @@ class RelabelApplicationServiceTest {
}
@Test
void receivedTransferPublishesFrozenQuoteFeeAndSettleOnceWithoutReleasingStockAgain() {
void receivedLegacyTransferSettlesOnceAndWaitsForConfirmedPaymentBeforePublishing() {
RelabelApplication app = transferCompletionFixture();
service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1");
service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1");
ArgumentCaptor<com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest> fee =
ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
verify(feeSources).publishApproved(eq("operator-1"), eq("RELABEL:FBR-CLOSURE"), fee.capture());
assertEquals(new BigDecimal("0.01"), fee.getValue().amount());
assertEquals("CNY", fee.getValue().currency()); assertEquals("RELABEL_ORDER", fee.getValue().businessType());
assertEquals("FBR-CLOSURE", fee.getValue().businessNo()); assertEquals(BigDecimal.ONE, fee.getValue().chargeableQuantity());
verifyNoInteractions(feeSources);
verify(walletCommands).publish(eq("SETTLE"), eq(10001L), eq("RELABEL_ORDER"), eq("FBR-CLOSURE"),
isNull(), eq("换标完成,结算费用"), eq(2L));
assertEquals("COMPLETED", app.getStatus()); assertEquals("COMPLETED", app.getFulfillmentStatus());
@@ -1212,11 +1233,12 @@ class RelabelApplicationServiceTest {
}
@Test
void feePublicationFailureNeverQueuesSettlement() {
void settlementCommandFailureNeverPublishesFee() {
RelabelApplication app = transferCompletionFixture();
when(feeSources.publishApproved(any(), any(), any())).thenThrow(new IllegalStateException("fee unavailable"));
when(walletCommands.publish(eq("SETTLE"), anyLong(), any(), any(), any(), any(), anyLong()))
.thenThrow(new IllegalStateException("wallet unavailable"));
assertThrows(IllegalStateException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1"));
verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong());
verifyNoInteractions(feeSources);
}
private RelabelApplication transferCompletionFixture() {
@@ -28,12 +28,14 @@ class ReturnCartonCountServiceTest {
@Mock ReturnInventoryBalanceMapper balances;
@Mock ReturnInventoryOperationMapper operations;
@Mock ReturnCartonCountSessionMapper sessions;
@Mock com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
ReturnCartonCountService service;
PdaWarehouseIdentity identity = new PdaWarehouseIdentity("op", "operator", 123L);
ReturnReceipt receipt;
ReturnInventoryCarton a, b;
@BeforeEach void setup() {
service = new ReturnCartonCountService();
ReflectionTestUtils.setField(service, "storageRent", storageRent);
ReflectionTestUtils.setField(service,"receipts",receipts);
ReflectionTestUtils.setField(service,"orders",orders);
ReflectionTestUtils.setField(service,"returnReceiptService",returnReceiptService);
@@ -82,6 +84,11 @@ class ReturnCartonCountServiceTest {
verify(items).deleteCarton(2L); verify(items).deleteCarton(3L); verify(items,times(2)).insert(any(ReturnInventoryCartonItem.class));
verifyNoInteractions(balances);
verify(returnReceiptService).refreshCountedTailCargo(receipt);
var mutationOrder=inOrder(storageRent,items);
mutationOrder.verify(storageRent).ensureReturnCountLot(1L);
mutationOrder.verify(items).deleteCarton(2L);
mutationOrder.verify(items).deleteCarton(3L);
mutationOrder.verify(storageRent).rebuildReturnShares(1L);
var countedLine = org.mockito.ArgumentCaptor.forClass(ReturnReceiptItem.class);
verify(receiptItems).updateById(countedLine.capture());
assertThat(countedLine.getValue().getBoxCount()).isEqualTo(2);
@@ -109,6 +116,17 @@ class ReturnCartonCountServiceTest {
verify(items,never()).deleteCarton(anyLong()); verify(cartons,never()).updateById(any(ReturnInventoryCarton.class)); verifyNoInteractions(balances);
assertThat(a.getCountStatus()).isEqualTo("PENDING");
}
/** 缺失原计租批次未通过核验时,不清点写库也不释放操作者会话。 */
@Test void rentEvidenceFailurePreventsCountWritesAndSessionRelease() {
prepare(List.of(expected("SKU1",10)));
when(items.lockCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",5)));
when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",5)));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"退货签收缺少计租原批次")).when(storageRent).ensureReturnCountLot(1L);
assertThatThrownBy(()->service.confirm(identity,"RR-1",unequal(3,7))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("缺少计租原批次");
verify(items,never()).deleteCarton(anyLong());verify(cartons,never()).updateById(any(ReturnInventoryCarton.class));
verify(sessions,never()).release(anyString(),anyLong());verifyNoInteractions(balances,returnReceiptService);verify(operations,never()).insert(any(ReturnInventoryOperation.class));
verify(storageRent,never()).rebuildReturnShares(anyLong());assertThat(a.getCountStatus()).isEqualTo("PENDING");
}
@Test void duplicateOrForeignBoxesCannotBeSubmitted() {
prepare(List.of(expected("SKU1",10)));
var request = new Confirm("dup",List.of(measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))),measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",5)))));
@@ -88,6 +88,7 @@ class ReturnInventoryServiceTest {
@BeforeEach
void setUp() {
service = new ReturnInventoryService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service, "discardMovements", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.repository.ReturnDiscardMovementRepository.class));
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(service, "cartons", cartons);
@@ -136,6 +136,7 @@ class ReturnReceiptServiceTest {
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new ReturnReceiptService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "202610200001");
ReflectionTestUtils.setField(service, "orders", orders);
@@ -40,6 +40,7 @@ class ReturnSkuConversionPersistenceTest {
ReturnInventoryBalanceMapper balances;
DataSourceTransactionManager transactions;
ReturnSkuMySqlTestDatabase mysql;
com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
@BeforeEach void setUp(TestInfo info) throws Exception {
boolean mysqlMode = Boolean.getBoolean("returnSku.test.mysql");
@@ -69,6 +70,8 @@ class ReturnSkuConversionPersistenceTest {
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var session = new SqlSessionTemplate(factory.getObject());
target = new ReturnSkuConversionService();
storageRent=mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class);
ReflectionTestUtils.setField(target,"storageRent",storageRent);
ReflectionTestUtils.setField(target, "conversions", session.getMapper(ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(target, "cartons", session.getMapper(ReturnInventoryCartonMapper.class));
ReflectionTestUtils.setField(target, "contents", session.getMapper(ReturnInventoryCartonItemMapper.class));
@@ -188,6 +191,27 @@ class ReturnSkuConversionPersistenceTest {
service.confirm(app(1)); assertThrows(ResponseStatusException.class, () -> service.reverse(app(1)));
}
/** 实物转换与精确计租迁移快照同事务保存,退回仅使用原快照。 */
@Test void applyAndReversePersistAndReuseOriginalRentShareSnapshot() {
fixture("BOX");String snapshot="[{\"lotId\":51,\"quantity\":8}]";
when(storageRent.convertReturnSkuShares("C","W","UNIT-1","A","B",8)).thenReturn(snapshot);
apply(1,11,8,"INITIAL");apply(1,11,8,"INITIAL");
assertEquals(snapshot,jdbc.queryForObject("SELECT storage_rent_shares_json FROM ll_return_sku_conversion",String.class));
service.reverse(app(1));service.reverse(app(1));
verify(storageRent,times(1)).convertReturnSkuShares("C","W","UNIT-1","A","B",8);
verify(storageRent,times(1)).reverseReturnSkuShares("C","W","UNIT-1","A","B",snapshot);
assertStock("A",20,0);
}
/** 缺失计租来源时,转换已做的商品、余额和台账必须全部回滚。 */
@Test void rentLineageFailureRollsBackSkuInventoryAndLedger() {
fixture("BOX");when(storageRent.convertReturnSkuShares(anyString(),anyString(),anyString(),anyString(),anyString(),anyLong())).thenThrow(new IllegalStateException("原计租份额缺失"));
assertThrows(IllegalStateException.class,()->apply(1,11,8,"INITIAL"));
assertStock("A",20,0);assertEquals(20,balance("A","total_quantity"));
assertEquals(0,count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='B'"));
assertEquals(0,count("SELECT COUNT(*) FROM ll_return_sku_conversion"));assertEquals(0,count("SELECT COUNT(*) FROM ll_return_packing_trace"));
}
@Test void onlyActualCompletedFactsProduceInventoryAndBulkCountsPieces() {
fixture("PARCEL"); var item = item(11, 1, "A", "B", 20); item.setOperationType("RETURN_BULK_SKU_LABEL");
service.apply(app(1), List.of(item), List.of(fact(11, 8, "RELABELLED"), fact(11, 12, "EXCEPTION")), "INITIAL");
@@ -68,6 +68,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock OverseasFeeSourceService feeSources;
@Mock com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees;
@Mock LastlegOrderOperationLogMapper operations;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock InboundPlanCartonMapper cartons;
@@ -128,6 +129,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "feeSources", feeSources);
ReflectionTestUtils.setField(service, "manualFees", manualFees);
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
@@ -243,6 +245,8 @@ class LastlegOrderServiceTest {
var response = service.detail("100", 11L);
verify(manualFees).describe(List.of());
assertEquals(11L, response.order().id());
assertEquals("100", response.order().customerId());
assertEquals("ONE_CLICK_DROPSHIP", response.order().orderType());
@@ -253,6 +257,20 @@ class LastlegOrderServiceTest {
.noneMatch(component -> "eventId".equals(component.getName())));
}
/** 换标履约详情同时读取旧订单费用及真实换标申请费用,避免更正归属后漏显示。 */
@Test
void relabelTailDetailIncludesApplicationAndLegacyOrderFees() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setOrderType("RELABEL_APPLICATION");
when(orders.selectOne(any())).thenReturn(order);
var legacy = new OverseasFeeSource(); legacy.setSourceFeeId(701L);
var application = new OverseasFeeSource(); application.setSourceFeeId(702L);
when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of(legacy));
when(manualFees.relabelFeesForOrder("100", 11L)).thenReturn(List.of(application));
when(manualFees.describe(List.of(legacy, application))).thenReturn(List.of(legacy, application));
assertEquals(List.of(legacy, application), service.detail("100", 11L).fees());
}
@Test
void returnTailDetailReadsReceiptFactsWithOwnedCustomerAndSourceReturnNumber() {
ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts);
@@ -315,21 +333,21 @@ class LastlegOrderServiceTest {
void createFeePersistsFeeAndOperationForOwnedOrder() {
when(orders.selectOne(any())).thenReturn(order(11L, "100", "PENDING_ASSIGNMENT", null));
var request = new LastlegOrderRequests.CreateFee(
"CUSTOMS_CLEARANCE", "清关费", "RECEIVABLE", "USD", new BigDecimal("420.00"),
"CUSTOMS_CLEARANCE", "清关费", "额外清关作业", "RECEIVABLE", "USD", new BigDecimal("420.00"),
LocalDate.of(2026, 9, 9), null, null, null, null, null, null, null, List.of(),
null, null, null, null, null);
OverseasFeeSource draft = new OverseasFeeSource();
draft.setSourceFeeId(701L); draft.setCustomerId("100"); draft.setFeeItemName("清关费");
draft.setOriginalCurrency("USD"); draft.setOriginalAmount(new BigDecimal("420.00"));
when(feeSources.create(eq("platform_admin"), eq("fee-key"), any())).thenReturn(draft);
when(feeSources.submit(701L, "platform_admin", "fee-key:submit")).thenReturn(draft);
when(manualFees.create(eq("platform_admin"), eq("fee-key"), any())).thenReturn(draft);
var fee = service.createFee("100", 11L, request, "platform_admin", "fee-key");
assertEquals("100", fee.getCustomerId());
assertEquals("清关费", fee.getFeeItemName());
verify(feeSources).create(eq("platform_admin"), eq("fee-key"), any());
verify(feeSources).submit(701L, "platform_admin", "fee-key:submit");
verify(manualFees).create(eq("platform_admin"), eq("fee-key"), any());
verify(feeSources, never()).create(any(), any(), any());
verify(feeSources, never()).submit(anyLong(), any(), any());
verify(operations).insert(any(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class));
}
@@ -408,8 +426,7 @@ class LastlegOrderServiceTest {
@Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null,
LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null);
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
resultPage.setTotal(0);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
@@ -561,12 +578,12 @@ class LastlegOrderServiceTest {
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10);
resultPage.setRecords(List.of(order));
resultPage.setTotal(1);
when(orders.selectList(any())).thenReturn(resultPage.getRecords());
when(orders.selectPage(any(), any())).thenReturn(resultPage);
when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of(
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null);
var result = service.page(null, query);
@@ -588,9 +605,9 @@ class LastlegOrderServiceTest {
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10);
resultPage.setRecords(List.of(relabel, looseboard, firstleg));
resultPage.setTotal(3);
when(orders.selectList(any())).thenReturn(resultPage.getRecords());
when(orders.selectPage(any(), any())).thenReturn(resultPage);
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null);
var result = service.page(null, query);
@@ -619,14 +636,14 @@ class LastlegOrderServiceTest {
row.setOverseasWarehouseName("洛杉矶仓");
resultPage.setRecords(matched ? List.of(row) : List.of());
resultPage.setTotal(matched ? 1 : 0);
when(orders.selectList(any())).thenReturn(resultPage.getRecords());
when(orders.selectPage(any(), any())).thenReturn(resultPage);
var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, input, null, null, null, null, null, null, null, null, null));
null, null, null, null, input, null, null, null, null, null, LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null));
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LastlegOrder>> filter =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(orders).selectList(filter.capture());
verify(orders).selectPage(any(), filter.capture());
var wrapper = (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LastlegOrder>) filter.getValue();
String warehouseSql = wrapper.getExpression().getNormal().getSqlSegment();
assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_code))"));
@@ -636,5 +653,4 @@ class LastlegOrderServiceTest {
assertEquals(matched ? 1 : 0, result.total());
assertEquals(matched ? 1 : 0, result.records().size());
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.storagerent.gateway;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.storagerent.client.CustomerStoragePricingClient;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.service.StorageRentCalculator;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 客户报价权威响应、网络故障及完整阶梯的关闭式校验。 */
class StorageRentPricingGatewayTest {
private final CustomerStoragePricingClient client=mock(CustomerStoragePricingClient.class);
private final StorageRentPricingGateway gateway=new StorageRentPricingGateway();
/** 验证服务令牌和客户身份由真实来源传递。 */
@BeforeEach void setup(){ReflectionTestUtils.setField(gateway,"client",client);ReflectionTestUtils.setField(gateway,"token","internal-test");}
/** 权威默认不写库且不缓存,客户后续配置可即时生效。 */
@Test void acceptsExplicitDefaultsAndCustomerOverrideWithoutCache(){
var defaults=StorageRentCalculator.defaults(101L);var custom=new Pricing("31",101L,2,"CNY",defaults.tiers(),"CUSTOMER");
when(client.pricing(101L,"internal-test")).thenReturn(ApiResponse.success(defaults),ApiResponse.success(custom));
assertThat(gateway.current("101")).isEqualTo(defaults);assertThat(gateway.current("101")).isEqualTo(custom);
verify(client,times(2)).pricing(101L,"internal-test");
}
/** 网络、业务异常或空响应不能以默认零费用放行。 */
@Test void failsClosedOnNetworkOrEmptyResponse(){
when(client.pricing(101L,"internal-test")).thenThrow(new IllegalStateException("offline"));
assertThatThrownBy(()->gateway.current("101")).hasMessageContaining("暂不可用");
reset(client);
when(client.pricing(101L,"internal-test")).thenReturn(null,ApiResponse.success(null));
assertThatThrownBy(()->gateway.current("101")).hasMessageContaining("不完整");assertThatThrownBy(()->gateway.current("101")).hasMessageContaining("不完整");
}
/** 公共 Long 序列化可能为字符串,客户和阶梯ID都必须无损读回。 */
@Test void readsWarehouseStringIdsAndUsesExactInternalRoute() throws Exception {
var json=new com.fasterxml.jackson.databind.ObjectMapper();
var value=json.readValue("{\"configId\":\"9007199254740993\",\"customerId\":\"101\",\"version\":2,\"currency\":\"CNY\",\"source\":\"CUSTOMER\",\"tiers\":[{\"id\":\"9007199254740995\",\"startDay\":1,\"endDay\":null,\"unitPrice\":2,\"sortNo\":1,\"feeType\":\"STORAGE\",\"billingUnit\":\"CBM\"},{\"id\":12,\"startDay\":1,\"endDay\":null,\"unitPrice\":5,\"sortNo\":2,\"feeType\":\"RETURN_STORAGE\",\"billingUnit\":\"PALLET\"}]}",Pricing.class);
StorageRentPricingGateway.validate(value,101L);assertThat(value.configId()).isEqualTo("9007199254740993");assertThat(value.tiers().get(0).id()).isEqualTo("9007199254740995");assertThat(value.tiers().get(1).id()).isEqualTo("12");
var method=CustomerStoragePricingClient.class.getMethod("pricing",long.class,String.class);
assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()).containsExactly("/internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing");
assertThat(method.getParameters()[1].getAnnotation(org.springframework.web.bind.annotation.RequestHeader.class).value()).isEqualTo("X-Internal-Service-Token");
}
/** 错客户、错币、漏组、混单位、间断、重叠、有限尾和负价均不能收费。 */
@Test void rejectsInvalidGroupsCurrencyAndOwnership(){
var base=StorageRentCalculator.defaults(101L);
for(var value:List.of(new Pricing("1",102L,1,"USD",base.tiers(),"CUSTOMER"),new Pricing("1",101L,1,"EUR",base.tiers(),"CUSTOMER"),new Pricing("1",101L,1,"USD",base.tiers().stream().filter(t->"STORAGE".equals(t.feeType())).toList(),"CUSTOMER")))
assertThatThrownBy(()->StorageRentPricingGateway.validate(value,101L)).hasMessageContaining("不完整");
for(var tier:List.of(new PricingTier(null,1,7,BigDecimal.ZERO,1,"STORAGE","PALLET"),new PricingTier(null,2,7,BigDecimal.ZERO,1,"STORAGE","CBM"),new PricingTier(null,1,8,BigDecimal.ZERO,1,"STORAGE","CBM"),new PricingTier(null,1,7,new BigDecimal("-1"),1,"STORAGE","CBM"))) {
var tiers=new ArrayList<>(base.tiers());tiers.set(0,tier);
assertThatThrownBy(()->StorageRentPricingGateway.validate(new Pricing("1",101L,1,"USD",tiers,"CUSTOMER"),101L)).hasMessageContaining("不完整");
}
var tiers=new ArrayList<>(base.tiers());tiers.set(4,new PricingTier(null,91,100,BigDecimal.ONE,5,"STORAGE","CBM"));
assertThatThrownBy(()->StorageRentPricingGateway.validate(new Pricing("1",101L,1,"USD",tiers,"CUSTOMER"),101L)).hasMessageContaining("不完整");
}
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.storagerent.service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** 已确认报价和分批出库口径的金额回归。 */
class StorageRentCalculatorTest {
private final LocalDateTime inbound = LocalDateTime.of(2026, 10, 1, 23, 30);
/** 入库时刻起算,跨零点不增加一天,任何不足二十四小时的余数进一天。 */
@Test
void roundsElapsedTwentyFourHoursInsteadOfCalendarDays() {
assertThat(StorageRentCalculator.days(inbound, inbound)).isZero();
assertThat(StorageRentCalculator.days(inbound, inbound.plusMinutes(31))).isEqualTo(1);
assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(1))).isEqualTo(1);
assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(1).plusNanos(1))).isEqualTo(2);
assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(7))).isEqualTo(7);
assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(7).plusNanos(1))).isEqualTo(8);
}
/** 中转免费期和每个收费阶梯连续累加,不能按最后档位乘全部天数。 */
@Test
void accumulatesTransitTiersAtEveryBoundary() {
long[] days = {0, 7, 8, 30, 31, 60, 61, 90, 91};
String[] amounts = {"0", "0", "0.8", "18.4", "19.4", "48.4", "49.9", "93.4", "95.4"};
for (int i = 0; i < days.length; i++)
assertThat(StorageRentCalculator.unitAmount("TRANSIT", days[i])).as("第%d天", days[i]).isEqualByComparingTo(amounts[i]);
}
/** 退货从第一个计费日开始,每个阶梯只承担自己的天数。 */
@Test
void accumulatesReturnTiersAtEveryBoundary() {
long[] days = {0, 1, 30, 31, 60, 61, 90, 91};
String[] amounts = {"0", "1.5", "45", "47", "105", "108", "195", "199"};
for (int i = 0; i < days.length; i++)
assertThat(StorageRentCalculator.unitAmount("RETURN", days[i])).as("第%d天", days[i]).isEqualByComparingTo(amounts[i]);
}
/** 十箱两方分两次出库,余货使用原入库时刻和原数量分母。 */
@Test
void pricesPartialTransitWithoutRestartingFreeDays() {
assertThat(amount("TRANSIT", 10, "2", "10", "4")).isEqualByComparingTo("1.92");
assertThat(amount("TRANSIT", 31, "2", "10", "6")).isEqualByComparingTo("23.28");
}
/** 已进位的一托按原商品比例分摊,剩余六成不能再次进位成一托。 */
@Test
void apportionsTheOriginalRoundedPalletAcrossShipments() {
assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("6");
assertThat(amount("RETURN", 40, "1", "100", "60")).isEqualByComparingTo("39");
var thirds = amount("RETURN", 10, "1", "3", "1");
assertThat(thirds.multiply(new BigDecimal("3"))).isEqualByComparingTo("15");
}
/** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */
@Test
void rejectsMissingMeasurementAndInvalidSourceQuantities() {
assertThatThrownBy(() -> amount("TRANSIT", 10, "0", "10", "1")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> amount("RETURN", 1, "1", "0", "1")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> amount("RETURN", 1, "1", "10", "11")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> StorageRentCalculator.days(inbound, inbound.minusNanos(1))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> StorageRentCalculator.unitAmount("UNKNOWN", 1)).isInstanceOf(IllegalArgumentException.class);
}
/** 样例统一以原始数量计算出库份额。 */
private BigDecimal amount(String rule, long days, String units, String original, String shipped) {
return StorageRentCalculator.amount(rule, inbound, inbound.plusDays(days), new BigDecimal(units), new BigDecimal(original), new BigDecimal(shipped));
}
}
@@ -0,0 +1,115 @@
package com.baiyue.lastleg.storagerent.service;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.storagerent.entity.StorageRentLot;
import com.baiyue.lastleg.storagerent.entity.StorageRentShare;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import java.time.LocalDateTime;
import java.util.*;
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.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 历史修复仅允许明确授权且凭证完整的原托,不能扩大为缺失计租批次的通用兜底。 */
class StorageRentLegacyReturnCountTest {
private static final String RECEIPT_NO="RT-0KVUX7JR6C4JZ1I0QAOYVGIY2";
private final StorageRentRepository repository=mock(StorageRentRepository.class);
private final StorageRentService service=new StorageRentService();
private ReturnReceipt receipt;
private ReturnReceiptBatch batch;
private List<ReturnInventoryCarton> boxes;
private List<ReturnReceiptItem> signedItems;
private final Map<Long,List<ReturnInventoryCartonItem>> boxItems=new HashMap<>();
/** 使用固定的测试签收时刻验证上海本地历史时间转 UTC,不读取或修改实际业务数据。 */
@BeforeEach void setup(){
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"legacyReturnCountReceiptNos",RECEIPT_NO);
ReflectionTestUtils.setField(service,"legacyReturnTimeZone","Asia/Shanghai");
receipt=new ReturnReceipt();receipt.setId(1L);receipt.setReturnOrderId(9L);receipt.setCustomerId("101");receipt.setReceiptNo(RECEIPT_NO);
receipt.setReceiptBatchNo("B-1");receipt.setUnitType("PALLET");receipt.setUnitCode("P-1");receipt.setPalletCode("P-1");receipt.setStatus("SIGNED");
receipt.setReceivedQuantity(6L);receipt.setTotalBoxCount(3L);receipt.setSignedAt(LocalDateTime.of(2026,10,8,10,30));
var order=new ReturnOrder();order.setId(9L);order.setCustomerId("101");order.setOverseasWarehouseId("21");order.setReturnNo("R-1");
batch=new ReturnReceiptBatch();batch.setReceiptId(1L);batch.setReturnOrderId(9L);batch.setCustomerId("101");batch.setOverseasWarehouseId("21");
batch.setBatchNo("B-1");batch.setStorageBillingStatus("PENDING_PRICING");batch.setStorageStartedAt(receipt.getSignedAt());
signedItems=List.of(signedItem("A",4),signedItem("B",2));
boxes=List.of(box(11,"A"),box(12,"A"),box(13,"B"));
when(repository.lockReturnReceipt(1L)).thenReturn(receipt);when(repository.returnOrder(9L)).thenReturn(order);
when(repository.returnReceiptBatches(1L)).thenAnswer(i->List.of(batch));when(repository.returnReceiptItems(1L)).thenAnswer(i->signedItems);
when(repository.receiptCartons(1L)).thenAnswer(i->boxes);when(repository.items(anyLong())).thenAnswer(i->boxItems.get(i.getArgument(0)));
}
/** 重复调用复用唯一来源,恢复原托时间和数量,不在修复期间生成资金或租金。 */
@Test void restoresOnlyOriginalLotAtActualHistoricalInstantAndIsIdempotent(){
var saved=new ArrayList<StorageRentLot>();
doAnswer(i->{saved.add(i.getArgument(0));return null;}).when(repository).insert(any(StorageRentLot.class));
when(repository.source("RETURN:1")).thenAnswer(i->saved.isEmpty()?null:saved.get(0));
service.ensureReturnCountLot(1L);service.ensureReturnCountLot(1L);
assertThat(saved).hasSize(1);var lot=saved.get(0);
assertThat(lot.getInboundAt()).isEqualTo(LocalDateTime.of(2026,10,8,2,30));assertThat(lot.getCreatedAt()).isAfter(lot.getInboundAt());
assertThat(lot.getOriginalUnits()).isEqualByComparingTo("1");assertThat(lot.getOriginalQuantity()).isEqualByComparingTo("6");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
assertThat(lot.getBusinessNo()).isEqualTo("R-1");assertThat(lot.getRuleCode()).isEqualTo("RETURN");
assertThat(receipt.getSignedAt()).isEqualTo(LocalDateTime.of(2026,10,8,10,30));
verify(repository,times(1)).lockReturnReceipt(1L);verify(repository,never()).insert(any(StorageRentShare.class));
}
/** 数量超过装箱 Long 缓存范围时仍按数值核对,避免引用比较误拒绝合法原托。 */
@Test void largeQuantityUsesNumericEquality(){
receipt.setReceivedQuantity(600L);signedItems=List.of(signedItem("A",400),signedItem("B",200));
boxes.forEach(box->{box.setTotalQuantity(200L);boxItems.get(box.getId()).get(0).setQuantity(200L);});
service.ensureReturnCountLot(1L);verify(repository).insert(any(StorageRentLot.class));
}
/** 默认不开放任何历史订单;白名单精确匹配,不按日期或任意新单自动恢复。 */
@ParameterizedTest @ValueSource(strings={"", "OTHER-RECEIPT", "RT-0KVUX7JR6C4JZ1I0QAOYVGIY"})
void unlistedMissingLotFailsClosed(String whitelist){
ReflectionTestUtils.setField(service,"legacyReturnCountReceiptNos",whitelist);
assertThatThrownBy(()->service.ensureReturnCountLot(1L)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("缺少计租原批次");
verify(repository,never()).insert(any(StorageRentLot.class));
}
/** 已有批次保持既有清点流程,不需要开启历史修复开关。 */
@Test void existingLotDoesNotRequireLegacySettings(){
ReflectionTestUtils.setField(service,"legacyReturnCountReceiptNos","");when(repository.source("RETURN:1")).thenReturn(new StorageRentLot());
service.ensureReturnCountLot(1L);verify(repository,never()).lockReturnReceipt(anyLong());verify(repository,never()).insert(any(StorageRentLot.class));
}
/** 时间凭证和库存任一不完整时整体阻断,不能靠清点输入重造原始事实。 */
@ParameterizedTest @ValueSource(strings={"timezone","signedAt","batchTime","missingBox","sku","counted","outbound","relabel","merged","reserved","sourceBatch"})
void rejectsChangedOrIncompleteOriginalEvidence(String scenario){
var box=boxes.get(0);
switch(scenario){
case "timezone" -> ReflectionTestUtils.setField(service,"legacyReturnTimeZone","");
case "signedAt" -> receipt.setSignedAt(null);
case "batchTime" -> batch.setStorageStartedAt(receipt.getSignedAt().plusHours(1));
case "missingBox" -> boxes=boxes.subList(0,2);
case "sku" -> boxItems.get(box.getId()).get(0).setSkuCode("CHANGED");
case "counted" -> box.setCountedAt(receipt.getSignedAt().plusHours(1));
case "outbound" -> box.setStatus("SHIPPED");
case "relabel" -> box.setRelabelApplicationId(99L);
case "merged" -> box.setMergedAt(receipt.getSignedAt().plusHours(1));
case "reserved" -> boxItems.get(box.getId()).get(0).setPackingReservedQuantity(1L);
case "sourceBatch" -> box.setSourceBatchNo("OTHER");
default -> throw new AssertionError(scenario);
}
assertThatThrownBy(()->service.ensureReturnCountLot(1L)).isInstanceOf(ResponseStatusException.class);
verify(repository,never()).insert(any(StorageRentLot.class));
}
/** 原始签收数量凭证。 */
private ReturnReceiptItem signedItem(String sku,long quantity){var item=new ReturnReceiptItem();item.setCustomerId("101");item.setSkuCode(sku);item.setReceivedQuantity(quantity);return item;}
/** 每箱保留原托、原批次、原业务及尚未清点的库存快照。 */
private ReturnInventoryCarton box(long id,String sku){
var box=new ReturnInventoryCarton();box.setId(id);box.setCartonMark("BOX-"+id);box.setCustomerId("101");box.setOverseasWarehouseId("21");
box.setSourceReceiptId(1L);box.setSourceBatchNo("B-1");box.setSourceReturnNo("R-1");box.setSourceUnitType("PALLET");box.setSourceUnitCode("P-1");box.setPalletCode("P-1");
box.setCountStatus("PENDING");box.setStatus("AVAILABLE");box.setTotalQuantity(2L);
var item=new ReturnInventoryCartonItem();item.setCustomerId("101");item.setSkuCode(sku);item.setQuantity(2L);item.setPackingReservedQuantity(0L);
boxItems.put(id,List.of(item));return box;
}
}
@@ -0,0 +1,138 @@
package com.baiyue.lastleg.storagerent.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.storagerent.entity.*;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 原托、合箱和换标始终保留原入库时间、基数与来源份额。 */
class StorageRentLineageTest {
private final StorageRentRepository repository=mock(StorageRentRepository.class);
private final StorageRentService service=new StorageRentService();
private final List<StorageRentShare> shares=new ArrayList<>();
private final Map<Long,StorageRentLot> lots=new HashMap<>();
private final Map<Long,Long> inboundLots=new HashMap<>();
/** 使用可变库存投影验证多轮正反操作的份额守恒。 */
@BeforeEach void setup(){
ReflectionTestUtils.setField(service,"repository",repository);ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules());
when(repository.shares(anyString(),anyString(),anyString(),anyString())).thenAnswer(i->shares.stream().filter(s->s.getQuantity().signum()>0&&Objects.equals(s.getCartonMark(),i.getArgument(2))&&Objects.equals(s.getSkuCode(),i.getArgument(3))).sorted(Comparator.comparing(StorageRentShare::getLotId)).toList());
when(repository.lockLot(anyLong())).thenAnswer(i->lots.get(i.getArgument(0)));
doAnswer(i->{shares.add(i.getArgument(0));return null;}).when(repository).insert(any(StorageRentShare.class));
doAnswer(i->{StorageRentLot lot=i.getArgument(0);lots.put(lot.getId(),lot);return null;}).when(repository).insert(any(StorageRentLot.class));
when(repository.lockSource(anyString())).thenAnswer(i->lots.values().stream().filter(l->Objects.equals(l.getSourceKey(),i.getArgument(0))).findFirst().orElse(null));
when(repository.inboundRentLot(anyLong())).thenAnswer(i->inboundLots.get(i.getArgument(0)));
doAnswer(i->{inboundLots.put(i.getArgument(0),i.getArgument(1));return null;}).when(repository).recordInboundRent(anyLong(),anyLong(),any());
when(repository.lockStockShare(anyLong(),anyLong(),anyString())).thenAnswer(i->shares.stream().filter(s->Objects.equals(s.getLotId(),i.getArgument(0))&&Objects.equals(s.getBalanceId(),i.getArgument(1))&&Objects.equals(s.getBatchNo(),i.getArgument(2))).findFirst().orElse(null));
}
/** 目标 SKU 已混有另一原托,退回仍只恢复本次转换的精确原托份额。 */
@Test void skuConversionAndReversalPreserveExactOriginalLots(){
addLot(1L,30);addLot(2L,10);addLot(3L,60);
shares.add(share(11L,1L,"BOX-1","OLD","4"));shares.add(share(12L,2L,"BOX-1","OLD","6"));shares.add(share(13L,3L,"BOX-1","NEW","20"));
var first=lots.get(1L).getInboundAt();var snapshot=service.convertReturnSkuShares("101","9","BOX-1","OLD","NEW",5);
assertThat(quantity(1L,"OLD")).isEqualByComparingTo("2");assertThat(quantity(2L,"OLD")).isEqualByComparingTo("3");
assertThat(quantity(1L,"NEW")).isEqualByComparingTo("2");assertThat(quantity(2L,"NEW")).isEqualByComparingTo("3");
service.reverseReturnSkuShares("101","9","BOX-1","OLD","NEW",snapshot);
assertThat(quantity(1L,"OLD")).isEqualByComparingTo("4");assertThat(quantity(2L,"OLD")).isEqualByComparingTo("6");assertThat(quantity(3L,"NEW")).isEqualByComparingTo("20");
assertThat(quantity(1L,"NEW")).isZero();assertThat(quantity(2L,"NEW")).isZero();assertThat(lots.get(1L).getInboundAt()).isEqualTo(first);
verify(repository,never()).insert(any(StorageRentLot.class));verify(repository,never()).save(any(StorageRentLot.class));
}
/** 合箱只移动商品对应原托份额,不会生成新托或新的免费期。 */
@Test void mergeSplitsEachOriginalLotProportionally(){
addLot(1L,30);addLot(2L,10);shares.add(share(11L,1L,"SOURCE","SKU","4"));shares.add(share(12L,2L,"SOURCE","SKU","6"));
service.moveReturnShares("101","9","SOURCE","TARGET","SKU",5);
assertThat(shares.stream().filter(s->"TARGET".equals(s.getCartonMark())).map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("5");
assertThat(quantity(1L,"SKU")).isEqualByComparingTo("4");assertThat(quantity(2L,"SKU")).isEqualByComparingTo("6");
verify(repository,never()).insert(any(StorageRentLot.class));
}
/** 服务进程默认上海时区时,实际入库仍在计租事务独立记录 UTC,避免错移八小时。 */
@Test void actualInboundRentClockDoesNotAssumeBusinessWallTimeIsUtc(){
TimeZone old=TimeZone.getDefault();TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
try{
var order=new LastlegOrder();order.setId(91L);order.setOrderNo("FIRST-1");order.setCustomerId("101");order.setOrderType("FIRST_LEG");order.setDestinationType("OVERSEAS");order.setOverseasWarehouseId("9");order.setVolumeCbm(new BigDecimal("2"));order.setPackageCount(10);
var receipt=new InboundStockReceipt();receipt.setId(101L);receipt.setInventoryBalanceId(21L);receipt.setReceivedPackages(4L);receipt.setBatchNo("B-1");receipt.setReceivedAt(LocalDateTime.now());
when(repository.lockOrder(91L)).thenReturn(order);
var before=LocalDateTime.now(ZoneOffset.UTC);service.registerInbound(receipt,order);var after=LocalDateTime.now(ZoneOffset.UTC);
var captured=org.mockito.ArgumentCaptor.forClass(StorageRentLot.class);verify(repository).insert(captured.capture());
var rent=captured.getValue();assertThat(rent.getInboundAt()).isBetween(before,after);assertThat(Duration.between(rent.getInboundAt(),receipt.getReceivedAt()).toHours()).isBetween(7L,8L);
assertThat(rent.getOriginalUnits()).isEqualByComparingTo("0.8");assertThat(rent.getRemainingQuantity()).isEqualByComparingTo("4");
}finally{TimeZone.setDefault(old);}
}
/** 实收重复回执不追加原计租基数。 */
@Test void duplicateActualReceiptDoesNotCreateAnotherLot(){
var order=new LastlegOrder();order.setOrderType("WAREHOUSE_TRANSFER");
var receipt=new InboundStockReceipt();receipt.setId(101L);when(repository.source("INBOUND:101")).thenReturn(new StorageRentLot());
service.registerInbound(receipt,order);verify(repository,never()).insert(any(StorageRentLot.class));
}
/** 头程多批补入库合并起点,部分出库后追加不能恢复旧出库数量或改变体积比例。 */
@Test void firstLegReceiptsShareFirstClockAndKeepOutboundQuantity(){
var order=firstLeg();var first=inbound(101,21,"B-1",4);
service.registerInbound(first,order);var lot=lots.values().iterator().next();
var firstTime=LocalDateTime.now(ZoneOffset.UTC).minusDays(12);lot.setInboundAt(firstTime);
lot.setRemainingQuantity(new BigDecimal("1"));shares.get(0).setQuantity(BigDecimal.ONE);
order.setVolumeCbm(new BigDecimal("999"));
var second=inbound(102,22,"B-2",6);service.registerInbound(second,order);
assertThat(lots).hasSize(1);assertThat(lot.getInboundAt()).isEqualTo(firstTime);
assertThat(lot.getOriginalQuantity()).isEqualByComparingTo("10");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("7");
assertThat(lot.getOriginalUnits()).isEqualByComparingTo("2");assertThat(shares).hasSize(2);
service.registerInbound(first,order);service.registerInbound(second,order);
assertThat(lot.getOriginalQuantity()).isEqualByComparingTo("10");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("7");
assertThat(shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("7");
}
/** 同库存批次已出完时仍复用零份额,且补入库不重开免费期。 */
@Test void firstLegTopUpReusesExhaustedShareAndDoesNotRestartClock(){
var order=firstLeg();service.registerInbound(inbound(101,21,"B-1",4),order);
var lot=lots.values().iterator().next();var first=LocalDateTime.now(ZoneOffset.UTC).minusDays(20);lot.setInboundAt(first);
lot.setRemainingQuantity(BigDecimal.ZERO);shares.get(0).setQuantity(BigDecimal.ZERO);
service.registerInbound(inbound(102,21,"B-1",6),order);
assertThat(lots).hasSize(1);assertThat(shares).hasSize(1);assertThat(shares.get(0).getQuantity()).isEqualByComparingTo("6");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");assertThat(lot.getInboundAt()).isEqualTo(first);
verify(repository,times(2)).recordInboundRent(anyLong(),eq(lot.getId()),any());
}
/** 非头程分批实收各自保留实际入库时间,不合并为头程批次。 */
@Test void otherInboundBatchesKeepIndependentClocks(){
var order=firstLeg();order.setOrderType("WAREHOUSE_TRANSFER");
service.registerInbound(inbound(101,21,"B-1",4),order);var first=lots.values().iterator().next();
var earlier=LocalDateTime.now(ZoneOffset.UTC).minusDays(10);first.setInboundAt(earlier);
var before=LocalDateTime.now(ZoneOffset.UTC);service.registerInbound(inbound(102,21,"B-2",6),order);
assertThat(lots).hasSize(2);assertThat(first.getInboundAt()).isEqualTo(earlier);
assertThat(lots.values().stream().filter(l->l!=first).findFirst().orElseThrow().getInboundAt()).isAfterOrEqualTo(before);
verify(repository,never()).recordInboundRent(anyLong(),anyLong(),any());
}
/** 构造同单同仓、体积按箱均摊的头程订单。 */
private LastlegOrder firstLeg(){var order=new LastlegOrder();order.setId(91L);order.setOrderNo("FIRST-1");order.setCustomerId("101");order.setOrderType("FIRST_LEG");order.setDestinationType("OVERSEAS");order.setOverseasWarehouseId("9");order.setVolumeCbm(new BigDecimal("2"));order.setPackageCount(10);when(repository.lockOrder(91L)).thenReturn(order);return order;}
/** 构造不同实收凭证,用于验证合批与凭证去重。 */
private InboundStockReceipt inbound(long id,long balance,String batch,long quantity){var receipt=new InboundStockReceipt();receipt.setId(id);receipt.setInventoryBalanceId(balance);receipt.setBatchNo(batch);receipt.setReceivedPackages(quantity);receipt.setReceivedAt(LocalDateTime.now());return receipt;}
/** 计租只覆盖明确自有海外仓头程、仓储转运,普通直送头程不纳入。 */
@Test void eligibilityUsesExplicitDestinationAndBusinessType(){
var order=new LastlegOrder();order.setOrderType("FIRST_LEG");order.setDestinationType("FBA");assertThat(StorageRentService.eligible(order)).isFalse();
order.setDestinationType("OVERSEAS");assertThat(StorageRentService.eligible(order)).isTrue();
order.setOrderType("WAREHOUSE_TRANSFER");order.setDestinationType(null);assertThat(StorageRentService.eligible(order)).isTrue();
}
/** 创建不同库龄的原托。 */
private void addLot(long id,int days){var lot=new StorageRentLot();lot.setId(id);lot.setCustomerId("101");lot.setWarehouseId("9");lot.setInboundAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(days));lot.setOriginalUnits(BigDecimal.ONE);lots.put(id,lot);}
/** 创建同箱中某 SKU 的原托份额。 */
private StorageRentShare share(long id,long lot,String carton,String sku,String quantity){var share=new StorageRentShare();share.setId(id);share.setLotId(lot);share.setCustomerId("101");share.setWarehouseId("9");share.setCartonMark(carton);share.setSkuCode(sku);share.setQuantity(new BigDecimal(quantity));return share;}
/** 同原托在所有当前箱中的总份额。 */
private BigDecimal quantity(long lot,String sku){return shares.stream().filter(s->s.getLotId()==lot&&sku.equals(s.getSkuCode())).map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);}
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.storagerent.service;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
import com.baiyue.lastleg.financefee.service.*;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.entity.*;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class StorageRentReviewSourceTest {
/** 混合来源逐行追溯原计租单据,不用换标业务单覆盖,并隔离错误客户和仓库。 */
@Test void describesOriginalSourcesForEachAllocationWithoutChangingAmounts() throws Exception {
var rents=mock(StorageRentRepository.class);
var json=new ObjectMapper().findAndRegisterModules();
var service=new StorageRentReviewService(rents,mock(OverseasManualFeeRepository.class),mock(OverseasFeeSourceService.class),
mock(OverseasFeeCurrencyConverter.class),mock(WalletCommandOutboxService.class),json);
var at=LocalDateTime.of(2026,10,1,0,0);
var allocations=List.of(1L,2L,3L,4L,5L).stream().map(id->new Allocation(id,null,BigDecimal.ONE,new BigDecimal("2.00"),at,10)).toList();
var plan=new Plan(new Owner("10","客户","RELABEL_ORDER",80L,"HB80",80L,null,"20"),allocations,at.plusDays(10),new BigDecimal("10.00"));
var rent=new StorageRentPreparation();rent.setId(90L);rent.setSourceFeeId(100L);rent.setWarehouseId("20");
rent.setExecutionNo("OUT90");rent.setConsumedAt(at.plusDays(10));rent.setPlanJson(json.writeValueAsString(plan));rent.setStatus("PENDING_REVIEW");
var fee=new OverseasFeeSource();fee.setSourceFeeId(100L);fee.setCreatedBy("STORAGE_RENT");fee.setFeeItemCode("FEE_TYPE_077");
fee.setCustomerId("10");fee.setBusinessNo("HB80");fee.setAmount(new BigDecimal("70.00"));
when(rents.forFees(List.of(100L))).thenReturn(List.of(rent));
when(rents.lots(List.of(1L,2L,3L,4L,5L))).thenReturn(List.of(
lot(1L,"10","20","LASTLEG_ORDER","WC1"),lot(2L,"10","20","RETURN_ORDER","TH2"),
lot(3L,"other","20","RETURN_ORDER","PRIVATE"),lot(4L,"10","other","RETURN_ORDER","OTHER-WAREHOUSE")));
service.describe(List.of(fee));
var lines=fee.getStorageRent().lines();
assertThat(lines).extracting(OverseasFeeSource.RentLine::sourceBusinessNo).containsExactly("WC1","TH2",null,null,null);
assertThat(lines).extracting(OverseasFeeSource.RentLine::sourceBusinessType).containsExactly("LASTLEG_ORDER","RETURN_ORDER",null,null,null);
assertThat(lines.get(0).batchNo()).isEqualTo("B1");
assertThat(fee.getBusinessNo()).isEqualTo("HB80");
assertThat(fee.getAmount()).isEqualByComparingTo("70.00");
assertThat(fee.getStorageRent().calculatedAmount()).isEqualByComparingTo("10.00");
assertThat(fee.getStorageRent().chargedAmount()).isNull();
verify(rents).lots(List.of(1L,2L,3L,4L,5L));
}
/** 构造原入库业务批次,不从现有出库订单推断来源。 */
private StorageRentLot lot(long id,String customer,String warehouse,String type,String number) {
var lot=new StorageRentLot();lot.setId(id);lot.setCustomerId(customer);lot.setWarehouseId(warehouse);
lot.setBusinessType(type);lot.setBusinessNo(number);lot.setBatchNo("B"+id);return lot;
}
}
@@ -0,0 +1,470 @@
package com.baiyue.lastleg.storagerent.service;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.entity.*;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
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.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 真实资金协议和分批库存扣减的状态机回归。 */
class StorageRentServiceTest {
private final StorageRentRepository repository=mock(StorageRentRepository.class);
private final StorageRentSourceResolver resolver=mock(StorageRentSourceResolver.class);
private final WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class);
private final OverseasFeeCurrencyConverter converter=mock(OverseasFeeCurrencyConverter.class);
private final OverseasFeeSourceService fees=mock(OverseasFeeSourceService.class);
private final StorageRentService service=new StorageRentService();
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
private final PrepareRequest request=new PrepareRequest("OUTBOUND","TASK-1",11L,21L,4L,null,null,"scan-1",List.of(),null,null);
private final com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository feeRepo=mock(com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository.class);
private final StorageRentReviewService reviews=new StorageRentReviewService(repository,feeRepo,fees,converter,wallet,json);
private OverseasFeeSource rentFee;
private com.baiyue.lastleg.financefee.entity.OverseasFeeFunding funding;
private StorageRentPreparation current;
private StorageRentLot lot;
private StorageRentShare share;
private Plan plan;
private long eventSequence;
/** 同一扫码保留插入的不可变预占快照,模拟持久化边界。 */
@BeforeEach void setup() {
ReflectionTestUtils.setField(service,"repository",repository);ReflectionTestUtils.setField(service,"resolver",resolver);
ReflectionTestUtils.setField(service,"wallet",wallet);ReflectionTestUtils.setField(service,"converter",converter);
ReflectionTestUtils.setField(service,"feeSources",fees);ReflectionTestUtils.setField(service,"json",json);
ReflectionTestUtils.setField(service,"rentReview",reviews);
var commands=new HashMap<String,com.baiyue.lastleg.financefee.entity.OverseasFeeCommand>();
when(feeRepo.command(anyString(),anyString(),anyString())).thenAnswer(i->commands.get(i.getArgument(0)+":"+i.getArgument(1)+":"+i.getArgument(2)));
doAnswer(i->{var c=(com.baiyue.lastleg.financefee.entity.OverseasFeeCommand)i.getArgument(0);commands.put(c.getOperatorId()+":"+c.getAction()+":"+c.getIdempotencyKey(),c);return null;}).when(feeRepo).insertCommand(any());
when(feeRepo.lockFee(anyLong())).thenAnswer(i->rentFee);
when(feeRepo.funding(anyLong())).thenAnswer(i->funding);
when(feeRepo.lockFunding(anyLong())).thenAnswer(i->funding);
doAnswer(i->{funding=i.getArgument(0);return null;}).when(feeRepo).insertFunding(any());
doAnswer(i->{rentFee=i.getArgument(0);return null;}).when(feeRepo).insertFee(any());
when(fees.createPendingStorageRent(anyString(),any(),any())).thenAnswer(i->{
var r=(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest)i.getArgument(1);
var c=(OverseasFeeCurrencyConverter.Conversion)i.getArgument(2);
rentFee=new OverseasFeeSource();rentFee.setId(702L);rentFee.setSourceFeeId(701L);rentFee.setSourceVersion(1);
rentFee.setCustomerId("101");rentFee.setCreatedBy("STORAGE_RENT");rentFee.setFeeItemCode("FEE_TYPE_077");
rentFee.setReviewStatus("PENDING");rentFee.setSourceStatus("ACTIVE");rentFee.setPublicationStatus("UNPUBLISHED");
rentFee.setBusinessType(r.businessType());rentFee.setOriginalCurrency(c.originalCurrency());rentFee.setOriginalAmount(c.originalAmount());
rentFee.setAmount(c.settlementAmount());rentFee.setExchangeRate(c.exchangeRate());rentFee.setBusinessDate(r.businessDate());return rentFee;
});
var at=LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1);
plan=new Plan(new Owner("101","客户","LASTLEG_ORDER",91L,"FIRST-1",91L,"IN-1","9"),
List.of(new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("1.92"),at.minusDays(10),10)),at,new BigDecimal("1.92"));
when(resolver.resolve(eq("9"),any(),any())).thenAnswer(i->plan);
when(resolver.resolve(eq("9"),any(),any(),any())).thenAnswer(i->plan);
when(repository.latest(anyString(),anyString(),anyString())).thenAnswer(i->current);
doAnswer(i->{current=i.getArgument(0);return null;}).when(repository).insert(any(StorageRentPreparation.class));
when(repository.lockPreparation(anyLong())).thenAnswer(i->current);
when(converter.convert(eq("USD"),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion("USD",i.getArgument(1),new BigDecimal("7.123456"),plan.calculatedAt().toString().substring(0,7),((BigDecimal)i.getArgument(1)).multiply(new BigDecimal("7.123456")).setScale(2,java.math.RoundingMode.HALF_UP)));
when(wallet.publish(anyString(),eq(101L),eq("STORAGE_RENT"),anyString(),any(),anyString(),anyLong())).thenAnswer(i->i.getArgument(0)+"-"+(++eventSequence));
lot=new StorageRentLot();lot.setId(51L);lot.setCustomerId("101");lot.setWarehouseId("9");lot.setOriginalQuantity(BigDecimal.TEN);lot.setRemainingQuantity(BigDecimal.TEN);
share=new StorageRentShare();share.setId(61L);share.setLotId(51L);share.setQuantity(BigDecimal.TEN);
when(repository.lockLot(51L)).thenReturn(lot);when(repository.lockShare(61L)).thenReturn(share);
var fee=new OverseasFeeSource();fee.setSourceFeeId(701L);when(fees.publishSettled(anyString(),anyString(),any(),any(),any())).thenReturn(fee);
}
/** 未收到成功冻结回执时,原库存与计租份额都不能扣减。 */
@Test void pendingOrFailedFreezeCannotConsumeInventory() {
assertThat(service.prepare("9",request).ready()).isFalse();
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("未冻结成功");
service.handle(completed("FREEZE",false,null,null,null));
assertThat(current.getStatus()).isEqualTo("FAILED");
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("未冻结成功");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");verify(repository,never()).save(any(StorageRentLot.class));verifyNoInteractions(fees);
}
/** 先冻结再出库,仓租仅记实际份额,财务回执前不发布已收费用。 */
@Test void confirmedFreezeConsumesOnlyActualSharesAndPublishesAfterSettlement() {
ready();service.consume("9",request);
assertThat(current.getStatus()).isEqualTo("PENDING_REVIEW");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
assertThat(share.getQuantity()).isEqualByComparingTo("6");assertThat(rentFee.getReviewStatus()).isEqualTo("PENDING");
verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
approve();var event=completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002");service.handle(event);service.handle(event);
assertThat(current.getStatus()).isEqualTo("SETTLED");assertThat(rentFee.getWalletSettlement().ledgerId()).isEqualTo("8002");
verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());verify(fees,never()).publishSettled(anyString(),anyString(),any(),any(),any());
}
/** 使用真实费用校验验证扣款回执只登记本地明细,重放不重复扣款或推送结算。 */
@ParameterizedTest @ValueSource(strings={"LASTLEG_ORDER","RETURN_ORDER","WAREHOUSE_TRANSFER","RELABEL_ORDER"})
void eachBusinessCreatesPendingRentBeforeApproval(String businessType) {
var o=plan.owner();plan=new Plan(new Owner(o.customerId(),o.customerName(),businessType,o.businessId(),o.businessNo(),o.orderId(),o.inboundCode(),o.warehouseId()),plan.allocations(),plan.calculatedAt(),plan.amount(),plan.pricing());
ready();service.consume("9",request);
assertThat(rentFee.getBusinessType()).isEqualTo(businessType);assertThat(rentFee.getReviewStatus()).isEqualTo("PENDING");
assertThat(rentFee.getWalletSettlement()).isNull();assertThat(current.getSourceFeeId()).isEqualTo(701L);
verify(wallet,times(1)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
/** 冻结请求重发复用同一尝试和金额,不重复占用客户余额授信。 */
@Test void preparationReplayDoesNotFreezeTwice() {
var first=service.prepare("9",request);var replay=service.prepare("9",request);
assertThat(replay.preparationId()).isEqualTo(first.preparationId());
verify(wallet,times(1)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
assertThatThrownBy(()->service.prepare("9",new PrepareRequest("OUTBOUND","TASK-1",11L,21L,5L,null,null,"scan-1",List.of(),null,null))).hasMessageContaining("不同的货物或数量");
}
/** 改价后有效重复和消费仍传原配置快照,不改变冻结金额或版本。 */
@Test void activePreparationAndConsumeUseSavedPricingAfterConfigurationChanges() throws Exception {
ready();var snapshot=json.readValue(current.getPlanJson(),Plan.class).pricing();var amount=current.getAmount();clearInvocations(resolver,converter);
var saved=plan;when(resolver.resolve(eq("9"),any(),any(),any())).thenAnswer(i->{assertThat((Pricing)i.getArgument(3)).isEqualTo(snapshot);return saved;});
when(resolver.resolve(eq("9"),any(),any())).thenThrow(new IllegalStateException("新报价不应读取"));
service.prepare("9",request);service.consume("9",request);
verify(resolver,times(2)).resolve(eq("9"),eq(request),any(),eq(snapshot));verify(resolver,never()).resolve(anyString(),any(),any());
assertThat(current.getAmount()).isEqualByComparingTo(amount);verifyNoInteractions(converter);
}
/** 跨入新计租日使原预占失效,真正重建时才读取最新配置重新冻结。 */
@Test void changedAgeRebuildReadsCurrentPricingRatherThanPreviousSnapshot(){
ready();var first=current;var a=plan.allocations().get(0);var changed=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),new BigDecimal("2.56"),a.inboundAt(),11)),plan.calculatedAt(),new BigDecimal("2.56"));
when(resolver.resolve(eq("9"),any(),any(),any())).thenReturn(changed);plan=changed;clearInvocations(resolver);
service.prepare("9",request);assertThat(current.getId()).isNotEqualTo(first.getId());assertThat(first.getStatus()).isEqualTo("EXPIRED");
verify(resolver).resolve(eq("9"),eq(request),any(),any());verify(resolver).resolve(eq("9"),eq(request),any());
}
/** 人民币配置不误用美元汇率,资金、财务来源和详情保持实际原币。 */
@Test void cnyPricingKeepsCurrencyAcrossFundingSettlementAndDetails(){
var defaults=StorageRentCalculator.defaults(101L);plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),plan.amount(),new Pricing("31",101L,2,"CNY",defaults.tiers(),"CUSTOMER"));
when(converter.convert(eq("CNY"),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion("CNY",i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1)));
ready();service.consume("9",request);approve();service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));
assertThat(rentFee.getOriginalCurrency()).isEqualTo("CNY");assertThat(rentFee.getAmount()).isEqualByComparingTo(rentFee.getOriginalAmount());
verify(converter,never()).convert(eq("USD"),any(),any());
}
/** 历次出库配置切换币种时,列表和详情按币种汇总,不把美元和人民币直接相加。 */
@Test void mixedCurrenciesRemainSeparatedInListAndDetail() throws Exception {
var usd=new StorageRentPreparation();usd.setId(1L);usd.setOriginalAmount(new BigDecimal("1.92"));usd.setAmount(new BigDecimal("13.68"));usd.setPlanJson(json.writeValueAsString(plan));
var pricing=plan.pricing();var cny=new StorageRentPreparation();cny.setId(2L);cny.setOriginalAmount(new BigDecimal("1.92"));cny.setAmount(new BigDecimal("1.92"));cny.setPlanJson(json.writeValueAsString(new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),plan.amount(),new Pricing("31",101L,2,"CNY",pricing.tiers(),"CUSTOMER"))));
lot.setInboundAt(plan.allocations().get(0).inboundAt());lot.setRuleCode("TRANSIT");when(repository.lot(51L)).thenReturn(lot);when(repository.charges(51L)).thenReturn(List.of(usd,cny));
var page=new com.baomidou.mybatisplus.extension.plugins.pagination.Page<StorageRentLot>(1,20);page.setRecords(List.of(lot));page.setTotal(1);when(repository.page(any(),eq(1L),eq(20L))).thenReturn(page);when(repository.charges(List.of(51L))).thenReturn(List.of(usd,cny));
for(var view:List.of(service.detail(51L).lot(),service.list(new Query(null,null,null,null,null,1L,20L)).records().get(0))){assertThat(view.generatedAmount()).isNull();assertThat(view.generatedCurrency()).isNull();assertThat(view.generatedAmounts()).containsExactly(new CurrencyAmount("CNY",new BigDecimal("1.92")),new CurrencyAmount("USD",new BigDecimal("1.92")));}
}
/** 已扣减请求仅恢复原扫码结果,不能再次扣减剩余批次。 */
@Test void consumedPreparationReplayReturnsOriginalSnapshot() {
ready();service.consume("9",request);clearInvocations(resolver);
assertThat(service.prepare("9",request).status()).isEqualTo("CONSUMED");verifyNoInteractions(resolver);
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("已扣减");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
}
/** Finance 失败回执不携带 holdId,必须落库失败态并用新版本恢复。 */
@Test void failedSettleWithNullHoldRetriesWithoutDecrementingAgain() {
ready();service.consume("9",request);approve();String old=current.getSettleEventId();
service.handle(completed("SETTLE_ACTUAL",false,null,null,null));
assertThat(current.getStatus()).isEqualTo("SETTLE_FAILED");assertThat(current.getHoldId()).isEqualTo("8001");
current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());
assertThat(current.getStatus()).isEqualTo("CONSUMED");assertThat(current.getSettleEventId()).isNotEqualTo(old);
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());
service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));assertThat(current.getStatus()).isEqualTo("SETTLED");
}
/** 未知扣款状态不能重发新版本,避免网络迟到导致第二次资金动作。 */
@Test void consumedWithoutCallbackIsNotBlindlyRetried() {
ready();service.consume("9",request);clearInvocations(wallet);
current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());verifyNoInteractions(wallet);
}
/** 取消冻结中的扫码,迟到成功回执必须立即补发解冻。 */
@Test void cancelledPendingFreezeReleasesWhenSuccessArrivesLate() {
service.prepare("9",request);service.cancel("9",current.getId());
assertThat(current.getStatus()).isEqualTo("CANCELLED");assertThat(current.getReleaseEventId()).isNull();
service.handle(completed("FREEZE",true,"FROZEN","8001",null));
assertThat(current.getReleaseEventId()).isNotBlank();assertThat(current.getReleaseStatus()).isEqualTo("PENDING");
assertThat(service.preparation("9",current.getId()).ready()).isFalse();verifyNoInteractions(fees);
}
/** 解冻失败保留原冻结身份,恢复任务重发且不产生应收。 */
@Test void failedReleaseWithNullHoldCanRecover() {
ready();service.cancel("9",current.getId());String first=current.getReleaseEventId();
service.handle(completed("RELEASE",false,null,null,null));assertThat(current.getReleaseStatus()).isEqualTo("FAILED");
current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());
assertThat(current.getReleaseEventId()).isNotEqualTo(first);assertThat(current.getReleaseStatus()).isEqualTo("PENDING");
service.handle(completed("RELEASE",true,"RELEASED","8001",null));assertThat(current.getReleaseStatus()).isEqualTo("SUCCEEDED");verifyNoInteractions(fees);
}
/** 空结果、未知结果或缺失完成时间不能转成可出库状态。 */
@Test void invalidResultsCannotAdvanceFundingState() {
service.prepare("9",request);
for(String result:Arrays.asList(null,"PENDING","SUCCEEDED")) {
var event=new WalletCommandCompletedV1(current.getFreezeEventId(),"FREEZE",101L,"STORAGE_RENT",current.getId().toString(),result,"FROZEN",current.getAmount(),null,null,null,"8001",null);
assertThatThrownBy(()->service.handle(event)).hasMessageContaining("结果不完整");
}
assertThat(current.getStatus()).isEqualTo("FREEZE_PENDING");
}
/** 回执必须属于原客户、金额和指令,不能用其他预占凭据冒充已收。 */
@Test void foreignCallbackIdentityCannotUnlockScan() {
service.prepare("9",request);
var valid=completed("FREEZE",true,"FROZEN","8001",null);
assertThatThrownBy(()->service.handle(new WalletCommandCompletedV1("other","FREEZE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","FROZEN",current.getAmount(),null,null,Instant.now(),"8001",null))).hasMessageContaining("指令不一致");
assertThatThrownBy(()->service.handle(new WalletCommandCompletedV1(valid.commandEventId(),"FREEZE",102L,"STORAGE_RENT",valid.businessNo(),"SUCCEEDED","FROZEN",current.getAmount(),null,null,Instant.now(),"8001",null))).hasMessageContaining("客户或金额不一致");
assertThatThrownBy(()->service.handle(new WalletCommandCompletedV1(valid.commandEventId(),"FREEZE",101L,"STORAGE_RENT",valid.businessNo(),"SUCCEEDED","FROZEN",BigDecimal.ONE,null,null,Instant.now(),"8001",null))).hasMessageContaining("客户或金额不一致");
assertThatThrownBy(()->service.handle(completed("FREEZE",true,"FROZEN",null,null))).hasMessageContaining("资金占用凭据");
}
/** PDA 预占超过有效期后即使曾成功冻结也不能继续扣库存。 */
@Test void expiredReadyPreparationBlocksScanAndReleasesFunds() {
ready();current.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(16));
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("预占已过期");
service.recover(current.getId());assertThat(current.getStatus()).isEqualTo("EXPIRED");assertThat(current.getReleaseEventId()).isNotBlank();
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");
}
/** 非零收费必须具备真实月度汇率,换算失败不能持久化资金预占。 */
@Test void paidRentCannotUseFallbackExchangeRate() {
when(converter.convert(anyString(),any(),any())).thenThrow(new IllegalStateException("缺少已确认汇率"));
assertThatThrownBy(()->service.prepare("9",request)).hasMessageContaining("汇率");assertThat(current).isNull();verifyNoInteractions(wallet);
}
/** 免费期无汇率仍允许实物出库,并保留零仓租记录和份额扣减。 */
@Test void freeStorageNeedsNoRateOrWalletButStillTracksActualOutbound() {
var a=plan.allocations().get(0);plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),BigDecimal.ZERO,a.inboundAt(),7)),plan.calculatedAt(),BigDecimal.ZERO);
assertThat(service.prepare("9",request).ready()).isTrue();assertThat(current.getExchangeRate()).isNull();
current.setExchangeRateMonth("2000-01");service.consume("9",request);
assertThat(current.getStatus()).isEqualTo("PENDING_REVIEW");assertThat(current.getConsumedAt()).isNotNull();assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
verifyNoInteractions(converter,wallet);assertThat(rentFee.getAmount()).isZero();
}
/** 没有计租来源的非适用业务不建立孤立零费用记录。 */
@Test void nonRentCargoDoesNotCreateFunding() {
plan=new Plan(plan.owner(),List.of(),plan.calculatedAt(),BigDecimal.ZERO);
assertThat(service.prepare("9",request).status()).isEqualTo("NOT_REQUIRED");service.consume("9",request);
assertThat(current).isNull();verifyNoInteractions(wallet,converter,fees);
}
/** 换标后的原来源仍属于计租货物,管理端旧直接出库路径不得绕过。 */
@Test void existingRentSharesBlockLegacyDirectStockoutEvenAfterOrderTypeChanged() {
var position=new InventoryPosition();position.setCustomerId("101");position.setOverseasWarehouseId("9");position.setBalanceId(71L);position.setBatchNo("B-1");
when(repository.position(21L)).thenReturn(position);when(repository.balance(71L)).thenReturn(new InventoryBalance());
when(repository.stockShares("101","9",71L,"B-1")).thenReturn(List.of(share));
assertThatThrownBy(()->service.requirePdaOutbound("101","9",21L)).hasMessageContaining("PDA");
}
/** 查询和取消不能从当前绑定仓访问其他仓的客户资金状态。 */
@Test void preparationAccessIsWarehouseScoped() {
service.prepare("9",request);assertThatThrownBy(()->service.preparation("10",current.getId())).hasMessageContaining("当前仓没有");
assertThatThrownBy(()->service.cancel("10",current.getId())).hasMessageContaining("当前仓没有");
}
/** 清仓库龄按最后实际出库停止,列表与详情不会受后续更新时间影响。 */
@Test void clearedLotStopsAgeAtActualLastOutboundAndExposesUtcOffset() throws Exception {
lot.setInboundAt(LocalDateTime.of(2026,1,1,0,0));lot.setRemainingQuantity(BigDecimal.ZERO);lot.setUpdatedAt(LocalDateTime.of(2026,10,1,0,0));lot.setRuleCode("TRANSIT");
var preparation=new StorageRentPreparation();preparation.setId(31L);preparation.setCreatedAt(LocalDateTime.of(2026,1,8,0,0));preparation.setConsumedAt(LocalDateTime.of(2026,1,9,0,0));preparation.setStatus("SETTLED");
preparation.setOriginalAmount(plan.amount());preparation.setAmount(new BigDecimal("13.68"));preparation.setPlanJson(json.writeValueAsString(plan));
when(repository.lot(51L)).thenReturn(lot);when(repository.charges(51L)).thenReturn(List.of(preparation));
var page=new com.baomidou.mybatisplus.extension.plugins.pagination.Page<StorageRentLot>(1,20);page.setRecords(List.of(lot));page.setTotal(1);
when(repository.page(any(),eq(1L),eq(20L))).thenReturn(page);when(repository.charges(List.of(51L))).thenReturn(List.of(preparation));
var detail=service.detail(51L);var listed=service.list(new Query(null,null,null,null,null,1L,20L)).records().get(0);
assertThat(detail.lot().storageDays()).isEqualTo(8);assertThat(listed.storageDays()).isEqualTo(8);
assertThat(json.writer().without(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).writeValueAsString(detail)).contains("2026-01-01T00:00:00Z").contains("2026-01-09T00:00:00Z");
}
/** 多批次同次出库的分币尾差只记一次,原币及人民币明细都能与冻结总额核平。 */
@Test void mixedLotDetailAmountsReconcileToExactFundingTotal() throws Exception {
var a=plan.allocations().get(0);var mixed=new Plan(plan.owner(),List.of(new Allocation(51L,61L,BigDecimal.ONE,new BigDecimal("0.005"),a.inboundAt(),10),new Allocation(52L,62L,BigDecimal.ONE,new BigDecimal("0.005"),a.inboundAt(),10)),plan.calculatedAt(),new BigDecimal("0.01"));
var value=new StorageRentPreparation();value.setId(31L);value.setOriginalAmount(new BigDecimal("0.01"));value.setAmount(new BigDecimal("0.07"));value.setPlanJson(json.writeValueAsString(mixed));
lot.setInboundAt(a.inboundAt());lot.setRuleCode("TRANSIT");var other=new StorageRentLot();other.setId(52L);other.setInboundAt(a.inboundAt());other.setRemainingQuantity(BigDecimal.TEN);other.setRuleCode("TRANSIT");
when(repository.lot(51L)).thenReturn(lot);when(repository.lot(52L)).thenReturn(other);when(repository.charges(51L)).thenReturn(List.of(value));when(repository.charges(52L)).thenReturn(List.of(value));
var first=service.detail(51L).charges().get(0);var second=service.detail(52L).charges().get(0);
assertThat(first.originalAmount().add(second.originalAmount())).isEqualByComparingTo("0.01");assertThat(first.settlementAmount().add(second.settlementAmount())).isEqualByComparingTo("0.07");
assertThat(first.originalAmount()).isNotNegative();assertThat(second.originalAmount()).isNotNegative();
}
/** 原托多次出库累计原币仓租,仅成功结算计入扣款,列表与详情保持一致。 */
@Test void originalLotShowsSettledAndPendingChargesSeparately() throws Exception {
lot.setInboundAt(plan.allocations().get(0).inboundAt());lot.setRuleCode("RETURN");
var values=new ArrayList<StorageRentPreparation>();
for(int i=0;i<3;i++){
var value=new StorageRentPreparation();value.setId(31L+i);value.setStatus(i<2?"SETTLED":"SETTLE_FAILED");
value.setOriginalAmount(new BigDecimal("0.75"));value.setAmount(new BigDecimal("5.00"));
value.setConsumedAt(plan.calculatedAt());value.setPlanJson(json.writeValueAsString(plan));values.add(value);
}
when(repository.lot(51L)).thenReturn(lot);when(repository.charges(51L)).thenReturn(values);
var page=new com.baomidou.mybatisplus.extension.plugins.pagination.Page<StorageRentLot>(1,20);page.setRecords(List.of(lot));page.setTotal(1);
when(repository.page(any(),eq(1L),eq(20L))).thenReturn(page);when(repository.charges(List.of(51L))).thenReturn(values);
for(var view:List.of(service.detail(51L).lot(),service.list(new Query(null,null,null,null,null,1L,20L)).records().get(0))){
assertThat(view.generatedAmount()).isEqualByComparingTo("2.25");assertThat(view.settledAmount()).isEqualByComparingTo("10.00");
assertThat(view.chargeCount()).isEqualTo(3);assertThat(view.settledChargeCount()).isEqualTo(2);
}
}
/** 审核前调价必须留说明,版本保留原计算快照,重复审核不再发扣款。 */
@Test void adjustmentPreservesOriginalBasisAndReviewIsIdempotent() throws Exception {
ready();var initialFreeze=completed("FREEZE",true,"FROZEN","8001",null);service.consume("9",request);
var old=rentFee;String snapshot=current.getPlanJson();
assertThatThrownBy(()->reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("2.00")," ","adjust-1"))).hasMessageContaining("说明");
var req=new AdjustRentRequest(1,new BigDecimal("2.00"),"人工核对增加仓租","adjust-1");reviews.adjust(701L,"operator",req);reviews.adjust(701L,"operator",req);
assertThat(old.getSourceStatus()).isEqualTo("SUPERSEDED");assertThat(rentFee.getSourceVersion()).isEqualTo(2);
assertThat(rentFee.getFeeRemark()).isEqualTo("人工核对增加仓租");assertThat(rentFee.getAmount()).isEqualByComparingTo("14.25");
assertThat(current.getPlanJson()).isEqualTo(snapshot);service.handle(initialFreeze);
assertThatThrownBy(()->reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("APPROVE",null,"stale-review",1))).hasMessageContaining("版本已变化");
approve();approve();verify(wallet,times(1)).publish(eq("SETTLE_ACTUAL"),eq(101L),eq("STORAGE_RENT"),anyString(),eq(new BigDecimal("14.25")),anyString(),anyLong());
verify(feeRepo,times(1)).insertFee(any());
assertThatThrownBy(()->reviews.adjust(701L,"operator",new AdjustRentRequest(2,BigDecimal.ONE,"审核后","adjust-2"))).hasMessageContaining("待审核");
}
/** 调低费用使用实际金额结算,失败保留原冻结,不重复库存扣减。 */
@Test void lowerAdjustedAmountSettlesAgainstOriginalHoldAndFailureRetainsIt(){
ready();service.consume("9",request);reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对减免","adjust-1"));approve();
service.handle(completed("SETTLE_ACTUAL",false,null,null,null));assertThat(current.getStatus()).isEqualTo("SETTLE_FAILED");
assertThat(current.getHoldId()).isEqualTo("8001");assertThat(rentFee.getWalletSettlement()).isNull();
current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());
service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));assertThat(current.getStatus()).isEqualTo("SETTLED");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
}
/** 调高后资金不足保留原冻结,后续重试只结算确认金额、不重复扣库存。 */
@Test void increasedRentFailureKeepsOriginalHoldAndRetriesConfirmedAmountOnly(){
ready();service.consume("9",request);var originalHold=funding.getCommandHistory().get("holdAmount");
reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("20.00"),"补收漏算仓租","increase-1"));approve();
var failed=completed("SETTLE_ACTUAL",false,null,null,null);service.handle(failed);service.handle(failed);
assertThat(current.getStatus()).isEqualTo("SETTLE_FAILED");assertThat(current.getHoldId()).isEqualTo("8001");
assertThat(funding.getCommandHistory().get("holdAmount")).isEqualTo(originalHold);
assertThat(rentFee.getPublicationStatus()).isEqualTo("UNPUBLISHED");assertThat(rentFee.getWalletSettlement()).isNull();
current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());
service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));
assertThat(current.getAmount()).isEqualByComparingTo("142.47");assertThat(current.getStatus()).isEqualTo("SETTLED");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");assertThat(share.getQuantity()).isEqualByComparingTo("6");
verify(wallet,times(2)).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),eq(new BigDecimal("142.47")),anyString(),anyLong());
verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());
}
/** 调价与审核同键不可携带不同内容,避免重试覆盖已经确认的金额或决定。 */
@Test void adjustmentAndReviewRejectReusedKeysWithDifferentContents(){
ready();service.consume("9",request);
reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对减免","adjust-1"));
assertThatThrownBy(()->reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.TEN,"改成十元","adjust-1"))).hasMessageContaining("不同仓租请求");
approve();
assertThatThrownBy(()->reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT","撤销审核","review-1",2))).hasMessageContaining("不同仓租请求");
assertThat(rentFee.getReviewStatus()).isEqualTo("APPROVED");assertThat(current.getOriginalAmount()).isEqualByComparingTo("1");
verify(wallet,times(1)).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
/** 资金回执必须绑定原冻结和实扣金额,伪造的成功不能进入已收费用。 */
@Test void successfulSettlementRejectsWrongAmountOrHold(){
ready();service.consume("9",request);approve();
var wrongAmount=new WalletCommandCompletedV1(current.getSettleEventId(),"SETTLE_ACTUAL",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","SETTLED",BigDecimal.ONE,null,null,Instant.now(),"8001","8002");
assertThatThrownBy(()->service.handle(wrongAmount)).hasMessageContaining("金额不一致");
assertThatThrownBy(()->service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","other-hold","8002"))).hasMessageContaining("冻结凭据不一致");
assertThat(current.getStatus()).isEqualTo("CONSUMED");assertThat(rentFee.getWalletSettlement()).isNull();
assertThat(rentFee.getPublicationStatus()).isEqualTo("UNPUBLISHED");
}
/** 调价增加后驳回仍按原冻结额释放,不按增加后的应收释放。 */
@Test void rejectedIncreaseReleasesOriginalHoldAmountAndRecoversOnlyRelease(){
ready();var held=current.getAmount();service.consume("9",request);
reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("20.00"),"补算","increase-1"));
reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT","不应收费","reject-1",2));
service.handle(completed("RELEASE",false,null,null,null));
current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());
var released=new WalletCommandCompletedV1(current.getReleaseEventId(),"RELEASE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","RELEASED",held,null,null,Instant.now(),"8001",null);
service.handle(released);service.handle(released);
assertThat(current.getStatus()).isEqualTo("REJECTED");assertThat(funding.getWalletStatus()).isEqualTo("RELEASED");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
verify(wallet,times(2)).publish(eq("RELEASE"),anyLong(),anyString(),anyString(),isNull(),anyString(),anyLong());
verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
/** 调成零元审核后只释放原占用,回执必须核对原冻结金额。 */
@Test void zeroAdjustmentReleasesOriginalAmountOnlyAfterApproval(){
ready();var held=current.getAmount();service.consume("9",request);
reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("0.00"),"免除仓租","adjust-1"));
verify(wallet,never()).publish(eq("RELEASE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());approve();
var result=new WalletCommandCompletedV1(current.getReleaseEventId(),"RELEASE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","RELEASED",held,null,null,Instant.now(),"8001",null);
service.handle(result);service.handle(result);assertThat(current.getStatus()).isEqualTo("SETTLED");assertThat(rentFee.getWalletSettlement()).isNull();
verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
/** 驳回说明必填,释放资金后仍保留已出库计费记录。 */
@Test void rejectedRentReleasesHoldWithoutRepeatingStock(){
ready();service.consume("9",request);
assertThatThrownBy(()->reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT"," ","reject-1",1))).hasMessageContaining("原因");
var req=new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT","费用有误","reject-1",1);reviews.review(701L,"operator","审核员",req);reviews.review(701L,"operator","审核员",req);
service.handle(completed("RELEASE",true,"RELEASED","8001",null));assertThat(current.getStatus()).isEqualTo("REJECTED");assertThat(funding.getWalletStatus()).isEqualTo("RELEASED");
assertThat(service.prepare("9",request).status()).isEqualTo("CONSUMED");verify(wallet,times(1)).publish(eq("RELEASE"),anyLong(),anyString(),anyString(),isNull(),anyString(),anyLong());
}
/** 免费出库调整收费先补冻结,冻结成功以前不开放审核。 */
@Test void zeroToPositiveAdjustmentWaitsForNewFreeze(){
plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),BigDecimal.ZERO);service.prepare("9",request);service.consume("9",request);
reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对补收","adjust-1"));assertThat(current.getStatus()).isEqualTo("REVIEW_FREEZE_PENDING");
assertThatThrownBy(this::approve).hasMessageContaining("尚未锁定");
service.handle(completed("FREEZE",true,"FROZEN","8001",null));approve();assertThat(current.getStatus()).isEqualTo("CONSUMED");
}
/** 跨计租日必须重新预校验,不可使用前一天的冻结放行库存。 */
@Test void changedChargeDayCannotConsumePreviousFreeze(){
ready();var original=plan.allocations().get(0);
plan=new Plan(plan.owner(),List.of(new Allocation(original.lotId(),original.shareId(),original.quantity(),original.amount().add(BigDecimal.ONE),original.inboundAt(),original.days()+1)),plan.calculatedAt().plusDays(1),plan.amount().add(BigDecimal.ONE),plan.pricing());
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("计费时间或货物已变化");
assertThat(current.getConsumedAt()).isNull();assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");
assertThat(share.getQuantity()).isEqualByComparingTo("10");verify(fees,never()).createPendingStorageRent(anyString(),any(),any());
}
/** 原免费仓租补收失败时保持未审核,补冻结成功后才可审核实扣。 */
@Test void zeroToPositiveFreezeFailureRecoversBeforeApproval(){
plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),BigDecimal.ZERO);service.prepare("9",request);service.consume("9",request);
reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对补收","adjust-1"));
service.handle(completed("FREEZE",false,null,null,null));
assertThat(current.getStatus()).isEqualTo("REVIEW_FREEZE_FAILED");assertThatThrownBy(this::approve).hasMessageContaining("尚未锁定");
current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());
service.handle(completed("FREEZE",true,"FROZEN","8001",null));approve();
service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));
assertThat(current.getStatus()).isEqualTo("SETTLED");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
verify(wallet,times(2)).publish(eq("FREEZE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());
}
/** 明细显示实际入库和出库时刻,未扣款不伪造实际扣款金额。 */
@Test void feeDetailsExposeActualTimesAndConfirmedDebitOnly(){
ready();service.consume("9",request);when(repository.forFees(List.of(701L))).thenReturn(List.of(current));reviews.describe(List.of(rentFee));
assertThat(rentFee.getStorageRent().outboundAt()).isEqualTo(current.getConsumedAt().atOffset(ZoneOffset.UTC));
assertThat(rentFee.getStorageRent().lines().get(0).inboundAt()).isEqualTo(plan.allocations().get(0).inboundAt().atOffset(ZoneOffset.UTC));assertThat(rentFee.getStorageRent().chargedAmount()).isNull();
approve();service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));reviews.describe(List.of(rentFee));assertThat(rentFee.getStorageRent().chargedAmount()).isEqualByComparingTo(current.getAmount());
}
/** 真实费用服务出库时写入待审核来源,零金额也入明细且不发账单。 */
@ParameterizedTest @ValueSource(strings={"0.00","1.92"})
void realSourceCreationPersistsPendingFeeWithoutSettlement(String amount){
var sourceMapper=mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper.class);
var commands=mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper.class);
var outbox=mock(com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService.class);
var real=new OverseasFeeSourceService(sourceMapper,commands,mock(com.baiyue.lastleg.financefee.service.OverseasFeeBusinessGuard.class),outbox,converter,json,mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class));
ReflectionTestUtils.setField(reviews,"sources",real);plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),new BigDecimal(amount));
service.prepare("9",request);if(new BigDecimal(amount).signum()>0)service.handle(completed("FREEZE",true,"FROZEN","8001",null));
service.consume("9",request);var saved=org.mockito.ArgumentCaptor.forClass(OverseasFeeSource.class);verify(sourceMapper,times(1)).insert(saved.capture());
assertThat(saved.getValue().getReviewStatus()).isEqualTo("PENDING");assertThat(saved.getValue().getWalletSettlement()).isNull();
assertThat(saved.getValue().getOriginalAmount()).isEqualByComparingTo(amount);assertThat(current.getSourceFeeId()).isEqualTo(saved.getValue().getSourceFeeId());
assertThat(funding.getWalletBusinessNo()).isEqualTo(current.getId().toString());verifyNoInteractions(outbox);
verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
/** 升级前已发出的结算仍按原回执收尾,不补审或再发一次扣款。 */
@Test void legacyConsumedSettlementStillCompletesOnce(){
ready();current.setConsumedAt(plan.calculatedAt());current.setStatus("CONSUMED");current.setSettleEventId("old-settle");
var event=new WalletCommandCompletedV1("old-settle","SETTLE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","SETTLED",current.getAmount(),null,null,Instant.now(),"8001","8002");
service.handle(event);service.handle(event);assertThat(current.getStatus()).isEqualTo("SETTLED");
verify(fees,times(1)).publishSettled(anyString(),anyString(),any(),any(),any());verify(fees,never()).createPendingStorageRent(anyString(),any(),any());
verify(wallet,times(1)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
}
/** 审批后才申请原冻结的实际扣费。 */
private void approve(){reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("APPROVE",null,"review-1",rentFee.getSourceVersion()));}
/** 开始一次已确认冻结的真实扫码。 */
private void ready(){service.prepare("9",request);service.handle(completed("FREEZE",true,"FROZEN","8001",null));}
/** Finance 按指令原金额回执,失败时 hold 和 ledger 均为空。 */
private WalletCommandCompletedV1 completed(String operation,boolean success,String status,String hold,String ledger){
String id=switch(operation){case "FREEZE"->current.getFreezeEventId();case "SETTLE_ACTUAL"->current.getSettleEventId();default->current.getReleaseEventId();};
return new WalletCommandCompletedV1(id,operation,101L,"STORAGE_RENT",current.getId().toString(),success?"SUCCEEDED":"FAILED",status,current.getAmount(),success?null:"FIN-WALLET-409",success?null:"稍后重试",Instant.now(),hold,ledger);
}
}
@@ -0,0 +1,123 @@
package com.baiyue.lastleg.storagerent.service;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.entity.*;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 实际库存、业务单和租金原托之间的归属校验,防止错客户与错工单扣款。 */
class StorageRentSourceResolverTest {
private final StorageRentRepository repository=mock(StorageRentRepository.class);
private final StorageRentSourceResolver service=new StorageRentSourceResolver();
private final com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway pricing=mock(com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway.class);
private final LocalDateTime at=LocalDateTime.of(2026,10,22,0,0);
private final PrepareRequest request=new PrepareRequest("DISPATCH","DSP-1",11L,21L,4L,null,null,"scan-1",null,null,null);
private DispatchAggregate dispatch;
private DispatchItem item;
private InventoryPosition position;
private LastlegOrder order;
/** 提供同仓同客户真实排车、库存及自有海外仓入库来源。 */
@BeforeEach void setup(){
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"pricingGateway",pricing);
when(pricing.current("101")).thenReturn(StorageRentCalculator.defaults(101L));
dispatch=new DispatchAggregate();dispatch.setId(1L);dispatch.setOverseasWarehouseId("9");dispatch.setStatus("ACTIVE");dispatch.setStage("LOADING");
item=new DispatchItem();item.setId(11L);item.setDispatchOrderId(1L);item.setOverseasWarehouseId("9");item.setCustomerId("101");item.setOrderId(91L);item.setWarehousePalletId(31L);
position=new InventoryPosition();position.setId(21L);position.setCustomerId("101");position.setOverseasWarehouseId("9");position.setPalletId(31L);position.setBalanceId(71L);position.setBatchNo("B-1");
var balance=new InventoryBalance();balance.setCustomerId("101");balance.setInboundOrderId(91L);
order=new LastlegOrder();order.setId(91L);order.setCustomerId("101");order.setOrderNo("FIRST-1");order.setOrderType("FIRST_LEG");order.setDestinationType("OVERSEAS");
var share=new StorageRentShare();share.setId(61L);share.setLotId(51L);share.setQuantity(BigDecimal.TEN);
var lot=lot(51L,"TRANSIT",10,BigDecimal.TEN);
when(repository.dispatch("DSP-1")).thenReturn(dispatch);when(repository.dispatchItem(11L)).thenReturn(item);
when(repository.position(21L)).thenReturn(position);when(repository.balance(71L)).thenReturn(balance);when(repository.order(91L)).thenReturn(order);
when(repository.stockShares("101","9",71L,"B-1")).thenReturn(List.of(share));when(repository.lockLot(51L)).thenReturn(lot);
}
/** 原头程货单独申请换标,租金应收归当前换标单,库龄仍来自原实收。 */
@Test void independentRelabelOwnsChargeWithoutResettingOriginalAge(){
item.setSourceBusinessType("RELABEL_ORDER");item.setSourceBusinessNo("RELABEL-1");
var source=new StorageRentBusinessSource();source.setId(81L);source.setBusinessNo("RELABEL-1");source.setLastlegOrderId(91L);when(repository.relabelNo("101","RELABEL-1")).thenReturn(source);
var plan=service.resolve("9",request,at);
assertThat(plan.owner().businessType()).isEqualTo("RELABEL_ORDER");assertThat(plan.owner().businessId()).isEqualTo(81L);
assertThat(plan.allocations()).hasSize(1);assertThat(plan.allocations().get(0).days()).isEqualTo(10);
assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4");assertThat(plan.amount()).isEqualByComparingTo("0.96");
}
/** 当前客户覆盖默认价;同一预占消费保存价,余货下次预占读新价且库龄不变。 */
@Test void currentCustomerPricingAndSavedSnapshotKeepOriginalAge(){
var first=customPricing("USD",1,"2","5");when(pricing.current("101")).thenReturn(first);
var prepared=service.resolve("9",request,at);assertThat(prepared.amount()).isEqualByComparingTo("8.00");
var second=customPricing("CNY",2,"9","20");when(pricing.current("101")).thenReturn(second);clearInvocations(pricing);
var consumed=service.resolve("9",request,at,prepared.pricing());assertThat(consumed.amount()).isEqualByComparingTo("8.00");verifyNoInteractions(pricing);
var remainder=new PrepareRequest("DISPATCH","DSP-1",11L,21L,6L,null,null,"remaining",null,null,null);
var later=service.resolve("9",remainder,at.plusDays(1));assertThat(later.amount()).isEqualByComparingTo("59.40");
assertThat(later.pricing().currency()).isEqualTo("CNY");assertThat(later.allocations().get(0).days()).isEqualTo(11);
assertThat(later.allocations().get(0).inboundAt()).isEqualTo(prepared.allocations().get(0).inboundAt());
}
/** 一个客户配置里的不同业务费率只能作用于自己的计费基数。 */
@Test void transitAndReturnTiersStaySeparate(){
var custom=customPricing("USD",1,"2","5");
assertThat(StorageRentCalculator.unitAmount(custom,"TRANSIT",10)).isEqualByComparingTo("20");
assertThat(StorageRentCalculator.unitAmount(custom,"RETURN",10)).isEqualByComparingTo("50");
}
/** 两组各自从首日到无限尾,便于验证客户覆盖和原币切换。 */
private Pricing customPricing(String currency,int version,String transit,String returns){return new Pricing("31",101L,version,currency,List.of(new PricingTier("1",1,null,new BigDecimal(transit),1,"STORAGE","CBM"),new PricingTier("2",1,null,new BigDecimal(returns),1,"RETURN_STORAGE","PALLET")),"CUSTOMER");}
/** 有计租义务却缺原实收份额时必须阻断,不能按零仓租放行。 */
@Test void eligibleInventoryWithoutLineageBlocksOutbound(){
when(repository.stockShares("101","9",71L,"B-1")).thenReturn(List.of());
assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("计租数量不完整");
order.setDestinationType("FBA");assertThat(service.resolve("9",request,at).allocations()).isEmpty();
}
/** 排车绑定仓、库存客户和原托必须同时一致,伪造任一来源都不能扣款。 */
@Test void mismatchedWarehouseCustomerOrPalletIsRejected(){
assertThatThrownBy(()->service.resolve("10",request,at)).hasMessageContaining("当前仓");
position.setCustomerId("102");assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("原客户和原托盘");
position.setCustomerId("101");position.setPalletId(32L);assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("原客户和原托盘");
when(repository.position(21L)).thenReturn(null);assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("原客户和原托盘");
}
/** 同客户同托盘也必须与排车单绑定的入库订单一致,避免把另一单的库龄记入当前单据。 */
@Test void mismatchedInboundDocumentIsRejectedEvenWhenCustomerWarehouseAndPalletMatch(){
item.setOrderId(92L);item.setInboundCode("IN-92");
var balance=new InventoryBalance();balance.setCustomerId("101");balance.setInboundOrderId(91L);balance.setInboundCode("IN-91");
when(repository.balance(71L)).thenReturn(balance);
assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("业务单据关联的入库订单");
}
/** 退货只计原托商品比例,四件从十件原托出库不再向上取一个整托。 */
@Test void returnSkuOutboundUsesOriginalPalletFractionAndCurrentReturnOrder(){
var task=new OutboundPdaTask();task.setOutboundOrderId(41L);task.setCustomerId("101");task.setStatus("PENDING");
var outbound=new OutboundOrder();outbound.setOverseasWarehouseId("9");outbound.setOutboundType("RETURN_OUTBOUND");
var line=new OutboundPalletItem();line.setOutboundOrderId(41L);line.setCustomerId("101");line.setCartonMarks("BOX-1");line.setSkuCode("SKU");line.setReturnSkuQuantity(4L);line.setInboundCode("RET-1");
var ret=new ReturnOrder();ret.setId(81L);ret.setReturnNo("RET-1");ret.setLastlegOrderId(91L);
var carton=new ReturnInventoryCarton();carton.setCustomerId("101");carton.setOverseasWarehouseId("9");
var share=new StorageRentShare();share.setId(62L);share.setLotId(52L);share.setQuantity(BigDecimal.TEN);
when(repository.task("9","TASK-1")).thenReturn(task);when(repository.outboundItem(12L)).thenReturn(line);when(repository.outbound(41L)).thenReturn(outbound);
when(repository.returnOrder("101","RET-1")).thenReturn(ret);when(repository.carton("BOX-1")).thenReturn(carton);when(repository.shares("101","9","BOX-1","SKU")).thenReturn(List.of(share));when(repository.lockLot(52L)).thenReturn(lot(52L,"RETURN",2,BigDecimal.TEN));
var r=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"SKU",4L);
var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("1.20");
assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4");
var wrong=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"OTHER",4L);
assertThatThrownBy(()->service.resolve("9",wrong,at)).hasMessageContaining("原出库单不一致");
}
/** 制造一个明确原托或原体积计费批次。 */
private StorageRentLot lot(long id,String rule,int days,BigDecimal quantity){var lot=new StorageRentLot();lot.setId(id);lot.setRuleCode(rule);lot.setInboundAt(at.minusDays(days));lot.setOriginalUnits(BigDecimal.ONE);lot.setOriginalQuantity(quantity);lot.setRemainingQuantity(quantity);return lot;}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.storagerent.service.preparation;
import com.baiyue.lastleg.storagerent.service.StorageRentService;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 整托预占只在同币种时合计原币,人民币资金总额始终可用于 PDA 确认。 */
class StorageRentPreparationServiceTest {
private final StorageRentService rents=mock(StorageRentService.class);
private final DeliveryTransferService delivery=mock(DeliveryTransferService.class);
private final StorageRentPreparationService service=new StorageRentPreparationService();
private final PrepareRequest request=new PrepareRequest("OUTBOUND_PALLET","TASK",null,null,null,null,"PALLET","scan",null,null,null);
private final PdaWarehouseIdentity actor=new PdaWarehouseIdentity("operator","操作员",9L);
/** 沿真实整托规划展开两个扫描,不在测试里假设前端可决定客户金额。 */
@BeforeEach void setup(){
ReflectionTestUtils.setField(service,"rents",rents);ReflectionTestUtils.setField(service,"delivery",delivery);
when(delivery.storageRentPalletRequests(eq("9"),eq("operator"),eq("TASK"),eq("PALLET"),any())).thenReturn(List.of(new DeliveryRequests.PdaPick(1L,11L,1L,"one"),new DeliveryRequests.PdaPick(2L,12L,1L,"two")));
}
/** 两个扫码期间发生币种切换仍只展示可加总的 CNY 金额。 */
@Test void mixedOriginalCurrenciesDoNotProduceFalseAggregate(){
when(rents.prepare(eq("9"),any())).thenReturn(result("USD","2","14","7"),result("CNY","3","3","1"));
var view=service.prepare(actor,request);assertThat(view.amount()).isEqualByComparingTo("17");assertThat(view.currency()).isEqualTo("CNY");
assertThat(view.originalAmount()).isNull();assertThat(view.originalCurrency()).isNull();assertThat(view.exchangeRate()).isNull();assertThat(view.ready()).isTrue();
}
/** 同币同汇率才保留原币合计与共同汇率。 */
@Test void sameOriginalCurrencyAggregatesWithItsRealCurrency(){
when(rents.prepare(eq("9"),any())).thenReturn(result("CNY","2","2","1"),result("CNY","3","3","1"));
var view=service.prepare(actor,request);assertThat(view.amount()).isEqualByComparingTo("5");assertThat(view.originalAmount()).isEqualByComparingTo("5");assertThat(view.originalCurrency()).isEqualTo("CNY");assertThat(view.exchangeRate()).isEqualByComparingTo("1");
}
/** 构造资金结果时明确原币与人民币,避免测试自身跨币相加。 */
private PreparationResult result(String currency,String original,String cny,String rate){return new PreparationResult("1","READY",true,new BigDecimal(cny),"CNY",new BigDecimal(original),currency,new BigDecimal(rate),"2026-10",null);}
}
@@ -31,6 +31,7 @@ class TransferReturnSourceTest {
ReflectionTestUtils.setField(repository, "items", items);
ReflectionTestUtils.setField(repository, "relabelItems", relabelItems);
var service = new WarehouseTransferService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service, "returnSources", repository);
ReflectionTestUtils.setField(service, "inventorySources", mock(com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository.class));
var order = new ReturnOrder();
@@ -25,6 +25,7 @@ class WarehouseTransferCartonCountTest {
WarehouseTransferCarton a, b;
@BeforeEach void setup() {
service = spy(new WarehouseTransferService());
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service, "transfers", orders);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class)));
@@ -64,6 +64,7 @@ class WarehouseTransferRelabelPersistenceTest {
ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(WarehouseResourceGateway.class));
ReflectionTestUtils.setField(inventory,"objectMapper",new ObjectMapper().findAndRegisterModules());
completion=new WarehouseTransferRelabelCompletionService();
org.springframework.test.util.ReflectionTestUtils.setField(completion, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(completion,"allocations",session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
ReflectionTestUtils.setField(completion,"bindings",session.getMapper(WarehouseTransferRelabelBindingMapper.class));
ReflectionTestUtils.setField(completion,"inventoryRows",session.getMapper(InventoryCommandMapper.class));
@@ -229,6 +229,7 @@ class WarehouseTransferServiceTest {
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new WarehouseTransferService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service, "transportTaskService", transportTasks);
var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository();
ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons);