package com.baiyue.lastleg.delivery; import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.model.*; import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; import com.baiyue.lastleg.delivery.service.*; import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.*; import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** 实际整车服务配合领域策略验证分组、数组、锁点及未知结果恢复。 */ class DispatchDeliveryServiceTest { private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class); private final DispatchDeliveryRepository repository=mock(DispatchDeliveryRepository.class); private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class); private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json); private final Actor actor=new Actor("OP-A",ActorMode.PLATFORM,null); private final List outbounds=new ArrayList<>(); private final List deliveries=new ArrayList<>(); private final List calls=new ArrayList<>(); private DispatchDeliveryService service; private Context context; private DispatchDeliveryArrangement arrangement; /** 保存真实领域结果,持久化回滚另由Mapper事务测试负责。 */ @BeforeEach void prepare() { context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"101","LAX9",52,3),cargo(3,"102","ONT8",53,4)),false); var numbers=mock(LastlegOrderNoGenerator.class); when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); service=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context); when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); when(repository.arrangement(eq(10L),anyBoolean())).thenAnswer(a->arrangement); when(repository.outbounds(eq(10L),anyBoolean())).thenAnswer(a->List.copyOf(outbounds)); when(repository.deliveries(eq(10L),anyBoolean())).thenAnswer(a->List.copyOf(deliveries)); doAnswer(a->{outbounds.add(a.getArgument(0));calls.add("OUTBOUND");return null;}).when(repository).create(any(OutboundOrder.class)); doAnswer(a->{deliveries.add(a.getArgument(0));calls.add("DELIVERY");return null;}).when(repository).create(any(DeliveryOrder.class)); doAnswer(a->{arrangement=a.getArgument(0);return null;}).when(repository).create(any(DispatchDeliveryArrangement.class)); when(repository.lockSource(anyString(),anyLong())).thenAnswer(a->{calls.add("SOURCE");var o=new LastlegOrder();o.setId(a.getArgument(1)); o.setCustomerId(a.getArgument(0));o.setCurrentStatus("INBOUND_COMPLETED");return o;}); when(fees.external(any())).thenReturn(Map.of("101",freight(),"102",freight())); when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenAnswer(a->{ArrangementFact f=a.getArgument(3);CommandIdentity i=a.getArgument(2); return new CommandResult("DP-10",Action.ARRANGE_DELIVERY,i.idempotencyKey(),4,Status.ACTIVE,Stage.AFTER_LOADING,DEPARTED, a.getArgument(4)?Effect.EXISTING:Effect.APPLIED,context.counts(),f.result(),false,a.getArgument(4));}); } /** 两客户三个目的地形成两出库三派送,全部子单先于任何来源锁。 */ @Test void createsCompleteCustomerArraysAndLocksSourcesOnlyAfterEveryChild() { var result=service.arrange(actor,"DP-10",request("KEY-1")); var payload=(ArrangementResult)result.result(); assertEquals(2,payload.outbounds().size());assertEquals(3,payload.deliveries().size()); assertEquals(9,payload.deliveries().stream().mapToLong(DeliveryReference::packageCount).sum()); assertEquals(2,payload.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8")).map(DeliveryReference::customerId).distinct().count()); assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY")); assertTrue(outbounds.stream().allMatch(o->"WAIT_OUTBOUND".equals(o.getStatus()))); var items=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class);verify(repository,times(3)).create(items.capture()); assertEquals(java.util.Set.of(1L,2L,3L),items.getAllValues().stream().map(OutboundPalletItem::getSourceAttributionId).collect(java.util.stream.Collectors.toSet())); assertTrue(items.getAllValues().stream().allMatch(i->i.getSourceOrderId()!=null&&i.getSourceInventoryPositionId()!=null&&i.getDispatchItemId()!=null)); verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString()); } /** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */ @Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() { var pda=new Actor("PDA-1",ActorMode.PDA,"1001"); var fact=service.prepareForDeparture(context,pda,"LOADING-1"); assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size()); assertEquals(4,fact.result().recordVersion()); assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null && d.getPickupMethod()==null && d.getLoadingType()==null)); assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null)); assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY")); verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any()); var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class); verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString()); assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty())); clearInvocations(fees,repository); assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1")); verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class)); assertThrows(org.springframework.web.server.ResponseStatusException.class, ()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY")); } /** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */ @Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() { assertThrows(org.springframework.web.server.ResponseStatusException.class, ()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1")); verifyNoInteractions(repository,fees); outbounds.add(new OutboundOrder()); assertThrows(org.springframework.web.server.ResponseStatusException.class, ()->service.prepareForDeparture(context,actor,"LOADING-1")); verifyNoInteractions(fees); } /** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */ @Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception { var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null); var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L); context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second), relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false); var result=(ArrangementResult)service.arrange(actor,"DP-10",request("RELABEL-ARRANGE")).result(); assertEquals(2,result.outbounds().size()); assertEquals(3,result.deliveries().size()); var ont8=result.deliveries().stream().filter(d->d.customerId().equals("101")&&d.destination().warehouseCode().equals("ONT8")) .findFirst().orElseThrow(); assertEquals(5,ont8.packageCount());assertEquals(1,ont8.palletCount()); assertEquals(2,result.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8")) .map(DeliveryReference::customerId).distinct().count()); List cargo=json.readValue(arrangement.getCargoJson(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); assertEquals(context.cargo(),cargo); var created=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class); verify(repository,times(4)).create(created.capture()); for (var item:created.getAllValues()) { var source=cargo.stream().filter(c->c.customerId().equals(item.getCustomerId()) && c.attributionId().equals(item.getSourceAttributionId())).findFirst().orElseThrow(); assertEquals(source.orderId(),item.getSourceOrderId()); assertNotEquals(source.sourceOrigin().sourceInboundOrderId(),item.getSourceOrderId()); assertEquals(source.dispatchItemId(),item.getDispatchItemId()); assertEquals(source.packages(),item.getPackageCount().longValue()); assertNull(item.getGrossWeightKg());assertNull(item.getVolumeCbm()); } assertEquals(3,cargo.stream().filter(c->c.sourceOrigin().productQuantity()==null).count()); assertTrue(outbounds.stream().allMatch(o->o.getGrossWeightKg()==null&&o.getVolumeCbm()==null)); assertTrue(deliveries.stream().allMatch(d->d.getTotalWeightKg()==null&&d.getTotalVolumeCbm()==null)); var orders=org.mockito.ArgumentCaptor.forClass(Long.class); verify(repository,times(4)).lockSource(anyString(),orders.capture()); assertEquals(List.of(1L,2L,3L,4L),orders.getAllValues()); } /** 原命令恢复先于新阶段和版本判断,不能再次写任何客户单据。 */ @Test void strictReplayDelegatesToAuthoritativeLedgerBeforeCreatingDocuments() { var snapshot=new CommandResult("DP-10",Action.ARRANGE_DELIVERY,"KEY-1",4,Status.ACTIVE,Stage.AFTER_LOADING, DEPARTED,Effect.APPLIED,context.counts(),null,true,false); when(execution.replay(any(),any(),any())).thenReturn(Optional.of(snapshot)); assertSame(snapshot,service.arrange(actor,"DP-10",request("KEY-1"))); verify(execution,never()).requireFreshArrangement(any(),anyLong()); verifyNoInteractions(repository,fees); } /** 少一客户、错组或过期货物摘要均拒绝,不产生部分单据。 */ @Test void incompleteGroupSetAndChangedCargoCannotCreatePartialTruck() { var full=request("KEY-1"); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.arrange(actor,"DP-10", new ArrangeDelivery(3L,"KEY-1",full.cargoHash(),full.loadingCompletionHash(),full.groups().subList(0,2)))); assertTrue(outbounds.isEmpty()); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.arrange(actor,"DP-10", new ArrangeDelivery(3L,"KEY-2","old-hash",full.loadingCompletionHash(),full.groups()))); assertTrue(deliveries.isEmpty()); } /** 发车只更改单据,所有客户仍共享调用方事务,不提供第二次库存扣减入口。 */ @Test void departureMarksEveryCustomerDocumentWithoutNewFeePublication() { service.arrange(actor,"DP-10",request("KEY-1"));clearInvocations(fees); var result=service.markDeparted(context,actor,DEPARTED,"DEPART-1"); assertEquals(9,result.shippedPackages());assertEquals(2,result.outbounds().size()); assertTrue(outbounds.stream().allMatch(o->"OUTBOUNDED".equals(o.getStatus()))); assertTrue(deliveries.stream().allMatch(d->"DELIVERING".equals(d.getStatus())&&d.getDispatchedAt()!=null)); verifyNoInteractions(fees); } /** 使用主流程已冻结的Long版本,缺字段不得被当成有效零。 */ @Test void publicVersionRequiresPresenceAndAcceptsExplicitZero() { assertThrows(IllegalArgumentException.class,()->new ArrangeDelivery(null,"K","cargo","load",List.of())); assertThrows(IllegalArgumentException.class,()->new ArrangeDelivery(-1L,"K","cargo","load",List.of())); assertEquals(Long.valueOf(0),new ArrangeDelivery(0L,"K","cargo","load",List.of()).recordVersion()); } /** 请求依照服务端实际共享DTO构造,不给客户或操作者字段。 */ private ArrangeDelivery request(String key) { return new ArrangeDelivery(3L,key,"cargo-hash","loading-hash",grouping.groups(context).stream().map(g->new ArrangeGroup(g.key(),g.cargoHash(), DEPARTED.plusSeconds(3600),"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",g.cargoHash(),List.of(),List.of())).toList()); } /** 本例费用域独立校验,外接报价的实际来源规则另有直接测试。 */ private FleetBookingFreight.Preview freight() { return new FleetBookingFreight.Preview("freight",true,List.of(),List.of()); } }