190 lines
14 KiB
Java
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()); }
|
|
}
|