merge: preserve TEST finance and rent changes with fleet photo updates
This commit is contained in:
+8
-10
@@ -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());
|
||||
|
||||
+1
-16
@@ -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");
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+2
@@ -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)));
|
||||
|
||||
+2
@@ -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));
|
||||
|
||||
+1
@@ -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));
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+15
@@ -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));
|
||||
}
|
||||
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
|
||||
|
||||
+1
@@ -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));
|
||||
|
||||
+2
-1
@@ -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"));
|
||||
}
|
||||
/** 真实权限上下文不泄漏到其他测试。 */
|
||||
|
||||
+49
@@ -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"));
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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 {
|
||||
|
||||
+34
@@ -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();
|
||||
}
|
||||
}
|
||||
+229
@@ -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();
|
||||
}
|
||||
}
|
||||
+39
@@ -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");
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+32
-92
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
+318
@@ -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());
|
||||
}
|
||||
}
|
||||
+513
@@ -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");
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -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))
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+22
-1
@@ -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")));
|
||||
|
||||
+76
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-7
@@ -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("未决资金");
|
||||
|
||||
+9
@@ -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
-1
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+21
@@ -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("方向无效");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+32
-10
@@ -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() {
|
||||
|
||||
+18
@@ -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)))));
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+24
@@ -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");
|
||||
|
||||
+31
-15
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+56
@@ -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("不完整");
|
||||
}
|
||||
}
|
||||
+72
@@ -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));
|
||||
}
|
||||
}
|
||||
+115
@@ -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;
|
||||
}
|
||||
}
|
||||
+138
@@ -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);}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+470
@@ -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);
|
||||
}
|
||||
}
|
||||
+123
@@ -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;}
|
||||
}
|
||||
+41
@@ -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);}
|
||||
}
|
||||
+1
@@ -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();
|
||||
|
||||
+1
@@ -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)));
|
||||
|
||||
+1
@@ -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));
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user