This commit is contained in:
2026-10-30 17:59:12 +08:00
parent 872215367b
commit 51a93a69af
48 changed files with 1143 additions and 74 deletions
@@ -633,6 +633,8 @@ 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,"exceptionWorkflow",mock(com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.class));
org.springframework.test.util.ReflectionTestUtils.setField(service,"outboundWorkflow",mock(DispatchOutboundPort.class));
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()));
@@ -133,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,"outboundWorkflow",mock(DispatchOutboundPort.class));
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));
}
@@ -49,7 +49,8 @@ class ExceptionOrderDispatchReturnSourceTest {
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
@@ -50,7 +50,8 @@ class ExceptionOrderLifecycleTest {
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
objectMapper = new ObjectMapper().findAndRegisterModules();
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "commands", commands);
@@ -29,7 +29,8 @@ class ExceptionOrderServiceTest {
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
@@ -31,7 +31,8 @@ class ExceptionReturnConcurrencyTest {
var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var sessions = new SqlSessionTemplate(factory.getObject());
var service = new ExceptionOrderService();
var service = new ExceptionOrderService(); // workflow
org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
ReflectionTestUtils.setField(service, "deliveryOrders", sessions.getMapper(DeliveryOrderMapper.class));
ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class));
var tx = new TransactionTemplate(new DataSourceTransactionManager(data));
@@ -16,6 +16,7 @@ import static org.mockito.Mockito.*;
class ExceptionReturnHistoryTest {
private final ExceptionOrderService service = new ExceptionOrderService();
{ org.springframework.test.util.ReflectionTestUtils.setField(service,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); }
private final LocalDateTime time = LocalDateTime.of(2026, 10, 3, 23, 24);
private List<ExceptionOrderResponses.ReturnReceipt> history(List<ReturnHistoryRow> rows) {
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.exceptionorder.dto.*;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
/** 验证异常退回建单与现场实收边界,不能用建单替代库存事实。 */
class ExceptionReturnInboundServiceTest {
private final ExceptionWorkflowRepository repository=mock(ExceptionWorkflowRepository.class);
private final ExceptionOrderService exceptions=mock(ExceptionOrderService.class);
private final LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class);
private final PalletPhysicalGateway pallets=mock(PalletPhysicalGateway.class);
private final WarehouseResourceGateway resources=mock(WarehouseResourceGateway.class);
private final ExceptionReturnInboundService service=new ExceptionReturnInboundService(repository,exceptions,numbers,pallets,resources);
private final PdaWarehouseIdentity identity=new PdaWarehouseIdentity("7","仓管",11L);
/** 构造关联客户与原尾程明确的待退回工单。 */
private ExceptionOrder order() {
var order=new ExceptionOrder(); order.setId(1L); order.setCustomerId("C1"); order.setExceptionNo("EX1"); order.setVersion(0L);
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE"); order.setStatus("PENDING");
order.setLastMileOrderId(9L); order.setOverseasWarehouseId("11"); order.setInboundCode("ORIGINAL"); return order;
}
/** 首次建单只冻结来源和待入库状态,重放不重复创建或实收。 */
@Test void generatesOnceAndNeverReceivesStock() {
var exception=order(); var original=new LastlegOrder();original.setCustomerId("C1");original.setId(9L);original.setOrderNo("WC9");
when(repository.lock("C1","EX1")).thenReturn(exception);when(repository.original(9L)).thenReturn(original);
when(numbers.next("LASTLEG")).thenReturn("WC10");
when(exceptions.detail("C1","EX1")).thenReturn(new ExceptionOrderResponses.Detail(null,List.of(new ExceptionOrderResponses.ReturnSource(2L,3L,"TP1","SKU",2,null,null)),List.of()));
var generated=service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"key",null));
assertThat(generated.getSourceOrderNo()).isEqualTo("WC9");assertThat(generated.getSourceInboundId()).isEqualTo("1");
assertThat(generated.getPackageCount()).isEqualTo(2);assertThat(generated.getReceivedPackageCount()).isZero();
assertThat(generated.getCurrentStatus()).isEqualTo("PENDING_INBOUND");assertThat(generated.getInboundCode()).isNull();
when(repository.returnOrder(exception)).thenReturn(generated);
assertThat(service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"retry",null))).isSameAs(generated);
verify(repository,times(1)).insert(any()); verify(repository).copyDetails(generated,exception);
verify(exceptions,never()).complete(any(),any(),any(),any());verifyNoInteractions(pallets,resources);
}
/** 跨仓任务既不能开始,也不能上传证据或写入实收。 */
@Test void foreignWarehouseFailsBeforeReadingOrRestoring() {
assertThatThrownBy(()->service.start(identity,"EX-OTHER")).hasMessageContaining("当前仓");
assertThatThrownBy(()->service.receive(identity,"EX-OTHER",new ExceptionReturnInboundService.Receipt(0L,"key","TP1","L1",List.of(),List.of(5L)))).hasMessageContaining("当前仓");
verifyNoInteractions(exceptions,pallets,resources);
}
/** 客户随冻结任务取值,目标仓、库位真实绑定后才调用原来源实收入账。 */
@Test void receiptUsesWarehouseTaskOwnershipAndAuthoritativeTarget() {
var exception=order(); exception.setStatus("PROCESSING");
when(repository.lockWarehouse("11","EX1")).thenReturn(exception);when(repository.returnOrder(exception)).thenReturn(new LastlegOrder());
when(pallets.resolve("TP1")).thenReturn(new PalletPhysicalGateway.PalletReference(13,"TP1",11,14L,"IN_USE",null,1));
when(resources.resolveLocation(11,"L1")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(14,"L1","STORAGE",null,"ACTIVE"));
service.receive(identity,"EX1",new ExceptionReturnInboundService.Receipt(2L,"receive-1","TP1","L1",List.of(new ExceptionReturnInboundService.Item(2L,3L,null,1)),List.of(5L)));
verify(exceptions).complete(eq("C1"),eq("7"),eq("EX1"),argThat(r->r.expectedVersion()==2 && r.receipts().get(0).targetPalletId()==13 && r.receipts().get(0).receivedPackages()==1 && r.evidenceFileIds().equals(List.of(5L))));
}
/** 错误托盘或库位不能写入任何库存。 */
@Test void mismatchedLocationCannotReceive() {
var exception=order();when(repository.lockWarehouse("11","EX1")).thenReturn(exception);when(repository.returnOrder(exception)).thenReturn(new LastlegOrder());
when(pallets.resolve("TP1")).thenReturn(new PalletPhysicalGateway.PalletReference(13,"TP1",11,99L,"IN_USE",null,1));
when(resources.resolveLocation(11,"L1")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(14,"L1","STORAGE",null,"ACTIVE"));
assertThatThrownBy(()->service.receive(identity,"EX1",new ExceptionReturnInboundService.Receipt(0L,"key","TP1","L1",List.of(new ExceptionReturnInboundService.Item(2L,3L,null,1)),List.of(5L)))).hasMessageContaining("实际绑定一致");
verifyNoInteractions(exceptions);
}
}
@@ -174,6 +174,7 @@ class ReturnCartonReturnPersistenceTest {
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() {
var business=new ExceptionOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
@@ -64,7 +64,7 @@ class OverseasProcurementServiceTest {
}
@Test
void customerOrderAlwaysStartsPendingReviewWithZeroInventory() {
void platformOrderStartsPendingReviewWithZeroInventory() {
AtomicReference<OverseasProcurementOrder> saved = new AtomicReference<>();
when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> {
saved.set(invocation.getArgument(0));
@@ -80,6 +80,19 @@ class OverseasProcurementServiceTest {
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status());
}
/** 客户采购在资金处理中或失败时均不能由平台提前审核。 */
@Test
void fundingStatesCannotBeReviewed() {
for (String status : List.of("FUNDING_PENDING", "FUNDING_FAILED")) {
var pending = order(status, 0, 0);
when(orders.lockNo("100", "HC1")).thenReturn(pending);
assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7",
new OverseasProcurementRequests.Review(0L,"APPROVE","funding-review","checked",10L)));
assertEquals(status, pending.getStatus());
}
verify(orders, never()).updateById(org.mockito.ArgumentMatchers.<OverseasProcurementOrder>any());
}
@Test
void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() {
OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0);
@@ -81,7 +81,7 @@ class UsTruckOrderServiceTest {
@Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString())).thenReturn("TK1");
when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows);
lenient().when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows);
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; });
var request = create("PALLET_15", "new-truck");
var detail = service.create("100", "operator", request);
@@ -96,6 +96,16 @@ class UsTruckOrderServiceTest {
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
}
/** 资金处理中与失败订单不能绕过审核直接推进履约。 */
@Test
void fundingStatesCannotBeReviewed() {
for (String state : List.of("FUNDING_PENDING", "FUNDING_FAILED")) {
order.setStatus(state);
assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator",
new UsTruckOrderRequests.Review(0L, "APPROVE", "funding-review", null, 1L)));
}
}
@Test
void reviewEntersDispatchWithoutInventingPallets() {
order.setPalletCount(0);
@@ -19,6 +19,7 @@ class OrderBudgetAuthorizationTest {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean OrderFundingPort funding() { return mock(OrderFundingPort.class); }
@Bean OrderBudgetService service() { return mock(OrderBudgetService.class); }
@Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); }
}
@@ -39,6 +40,19 @@ class OrderBudgetAuthorizationTest {
assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(context.getBean(OrderBudgetService.class));
}
@Test void orderFundingReadAndRetryRespectSeparateBusinessPermissions() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var service=context.getBean(OrderBudgetService.class);
var reader=login(9,"MERCHANT","merchant:cross-border:procurement:list");
controller.readOrder(reader,"procurement","CG1");
verify(service).readOrder("9","procurement","CG1");
assertThatThrownBy(()->controller.readOrder(reader,"trucking","CG1")).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.retry(reader,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
var writer=login(9,"MERCHANT","merchant:cross-border:domestic-trucking:create");
controller.retry(writer,"trucking","KC1");
verify(service).retryOrder("9","trucking","KC1");
assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
@@ -18,17 +18,65 @@ import static org.assertj.core.api.Assertions.*;
class OrderBudgetPersistenceTest {
OrderBudgetRepository repository;
JdbcTemplate jdbc;
OrderBudgetService service;
OrderFundingPort funding;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
jdbc = new JdbcTemplate(ds);
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql);
assertThat(match.find()).isTrue();jdbc.execute(match.group());
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class);
config.addMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
repository = new OrderBudgetRepository(new SqlSessionTemplate(factory.getObject()).getMapper(OrderBudgetMapper.class));
var sessions = new SqlSessionTemplate(factory.getObject());
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
var wallet = new com.baiyue.lastleg.wallet.WalletCommandOutboxService(
sessions.getMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class),
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
var target = new OrderBudgetService(repository,
new com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter(null), wallet);
funding = org.mockito.Mockito.mock(OrderFundingPort.class);
org.springframework.test.util.ReflectionTestUtils.setField(target,"funding",funding);
var proxy = new org.springframework.aop.framework.ProxyFactory(target);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(
new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds),
new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
service = (OrderBudgetService) proxy.getProxy();
}
/** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */
@Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() {
for (String kind : new String[]{"procurement","trucking"}) {
String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order";
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
throw new IllegalStateException("订单明细写入失败");
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM " + table,Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_order_budget_reservation",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isZero();
}
}
/** 成功受理原子提交并可同键重放,资金指令不能重复。 */
@Test void acceptsAndReplaysWithoutDuplicateFreeze() {
for (String kind : new String[]{"procurement","trucking"}) {
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING");
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
}
/** 免费样品无需发出冻结指令,直接开放审核门禁。 */
@Test void freeSampleSkipsFinanceAndEntersReview() {
service.submit("9","procurement","zero",BigDecimal.ZERO,"CNY",()->"FREE1",v->v);
assertThat(repository.lockOrder("9","procurement","FREE1").getStatus()).isEqualTo("FROZEN");
org.mockito.Mockito.verify(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("PENDING_REVIEW"),org.mockito.ArgumentMatchers.isNull());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isZero();
}
@Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() {
var row = row(1,"FROZEN");repository.insert(row);
@@ -15,23 +15,35 @@ class OrderBudgetServiceTest {
OrderBudgetRepository repository=mock(OrderBudgetRepository.class);
WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class);
OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet);
OrderFundingPort funding=mock(OrderFundingPort.class);
OrderBudget row;
@BeforeEach void setup() {
org.springframework.test.util.ReflectionTestUtils.setField(service,"funding",funding);
row=new OrderBudget();row.setId(11L);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key");row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));row.setStatus("FREEZE_PENDING");row.setCommandEventId("freeze");row.setCommandVersion(1L);row.setReleaseRequested(false);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
when(repository.lock("9","procurement","key")).thenReturn(row);when(repository.lockId(11L,"9")).thenReturn(row);
}
@Test void insufficientNeverCreatesOrderAndExplicitRetryUsesNewCommand() {
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->{throw new AssertionError("不得建单");},v->"CG1")).hasMessageContaining("处理中");
/** 下单先受理,资金不足保留失败单,明确重试才发送新版本。 */
@Test void acceptsPendingThenRecordsFailureAndRetriesSameOrder() {
assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");
assertThat(row.getOrderNo()).isEqualTo("CG1");
service.handle(result("freeze","FREEZE","FAILED",null,"可用余额和授信额度不足"));
verify(funding).apply(row,"FUNDING_FAILED","可用余额和授信额度不足");
assertThat(service.read("9","procurement","key").message()).contains("授信额度不足");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("授信额度不足");
when(repository.lockOrder("9","procurement","CG1")).thenReturn(row);
when(wallet.publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("retry");
assertThat(service.prepare("9","procurement","key",new BigDecimal("100"),"USD").status()).isEqualTo("FREEZE_PENDING");assertThat(row.getCommandEventId()).isEqualTo("retry");
assertThat(service.retryOrder("9","procurement","CG1").status()).isEqualTo("FREEZE_PENDING");
verify(funding).apply(row,"FUNDING_PENDING",null);
service.retryOrder("9","procurement","CG1");
verify(wallet,times(1)).publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L));
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));
assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("retry","FREEZE","SUCCEEDED","FROZEN",null));
verify(funding).apply(row,"PENDING_REVIEW","");
}
@Test void latestSuccessfulReceiptAndExactCustomerAmountCurrencyAreRequired() {
service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));
assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("校验");
assertThatThrownBy(()->service.readOrder("10","procurement","CG1")).hasMessageContaining("未找到");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG1",v->v)).hasMessageContaining("变化");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG1",v->v)).hasMessageContaining("变化");
assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");assertThat(row.getOrderNo()).isEqualTo("CG1");
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.procurement.funding.OrderFundingService;
import com.baiyue.lastleg.procurement.funding.OrderFundingRepository;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
/** 两业务共享状态门禁,重复与迟到回执不能修改已取消或已审核单。 */
class OrderFundingServiceTest {
OrderFundingRepository repository = mock(OrderFundingRepository.class);
OrderFundingService service = new OrderFundingService(repository);
/** 采购冻结成功后才待审核,重复成功无第二条历史。 */
@Test void purchaseSuccessIsIdempotentAndPreservesRemark() {
var budget = budget("procurement");
var order = new OverseasProcurementOrder(); order.setId(1L); order.setCustomerId("9"); order.setStatus("FUNDING_PENDING"); order.setVersion(0L); order.setRemark("客户原始备注");
when(repository.purchase("9", "NO1")).thenReturn(order);
service.apply(budget,"PENDING_REVIEW",null); service.apply(budget,"PENDING_REVIEW",null);
assertThat(order.getStatus()).isEqualTo("PENDING_REVIEW"); assertThat(order.getVersion()).isEqualTo(1L);
assertThat(order.getRemark()).isEqualTo("客户原始备注");
verify(repository,times(1)).history(eq(order),eq("FUNDING_PENDING"),eq("PENDING_REVIEW"),isNull(),any());
}
/** 卡车失败保留备注,重试成功后才开放审核;取消后迟到结果不得复活。 */
@Test void truckFailureRetrySuccessAndCancellation() {
var budget = budget("trucking");
var order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("9"); order.setStatus("FUNDING_PENDING"); order.setVersion(0L); order.setRemark("客户路线说明");
when(repository.truck("9", "NO1")).thenReturn(order);
service.apply(budget,"FUNDING_FAILED","余额不足");
assertThat(order.getStatus()).isEqualTo("FUNDING_FAILED"); assertThat(order.getRemark()).isEqualTo("客户路线说明");
service.apply(budget,"FUNDING_PENDING",null); service.apply(budget,"PENDING_REVIEW",null);
assertThat(order.getStatus()).isEqualTo("PENDING_REVIEW");
order.setStatus("CANCELLED"); service.apply(budget,"PENDING_REVIEW",null);
assertThat(order.getStatus()).isEqualTo("CANCELLED");
verify(repository,times(3)).history(eq(order),anyString(),anyString(),nullable(String.class),any());
}
/** 资金状态必须定位可信客户范围的原单。 */
@Test void missingCustomerOrderRejectsReceipt() {
assertThatThrownBy(() -> service.apply(budget("trucking"),"PENDING_REVIEW",null)).isInstanceOf(IllegalStateException.class);
verify(repository).truck("9","NO1");
}
/** 构造已绑定原单的预算身份。 */
private OrderBudget budget(String kind) {
var row = new OrderBudget(); row.setCustomerId("9"); row.setKind(kind); row.setOrderNo("NO1"); return row;
}
}