Files
baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java
T
2026-10-21 14:30:41 +08:00

190 lines
14 KiB
Java

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<OutboundOrder> outbounds=new ArrayList<>();
private final List<DeliveryOrder> deliveries=new ArrayList<>();
private final List<String> 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<CargoLine> 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()); }
}