This commit is contained in:
2026-10-19 10:23:09 +08:00
parent 45fbd6eff8
commit e8f6bb32dc
30 changed files with 1612 additions and 49 deletions
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.*;
class FleetPickupMapperTest {
JdbcTemplate db;FleetPickupMapper mapper;
@BeforeEach void setup() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)");
db.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),updated_at TIMESTAMP)");
for(int i=1;i<=6;i++)db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,? ,'100','Ontario Test Address','Contact','0000000000',5,CURRENT_TIMESTAMP,'WAIT_PDA_RECEIVING','WAREHOUSE_PICKUP',CURRENT_TIMESTAMP)",i,"C"+i,"WT-"+i,"WT-"+i,"LL-"+i);
db.update("UPDATE ll_warehouse_transfer_order SET delivery_method='CUSTOMER_DELIVERY' WHERE id=2");
db.update("UPDATE ll_warehouse_transfer_order SET status='PENDING_REVIEW' WHERE id=3");
db.update("UPDATE ll_warehouse_transfer_order SET status='WAIT_CUSTOMER_CONFIRMATION' WHERE id=4");
db.update("UPDATE ll_warehouse_transfer_order SET inbound_code='OLD-INBOUND' WHERE id=5");
var config=new MybatisConfiguration();config.addMapper(FleetPickupMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class);
}
@Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() {
assertThat(mapper.countCandidates(null)).isEqualTo(2);
assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1");
assertThat(mapper.candidates(null,1,1)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-6");
assertThat(mapper.countCandidates("WT-6")).isEqualTo(1);
assertThat(mapper.source("C2","WT-1")).isNull();assertThat(mapper.source("C1","WT-1").getPackageCount()).isEqualTo(5);
}
@Test void assignmentDisappearsFromPoolAndCancellationRestoresIt() {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)");
assertThat(mapper.countCandidates(null)).isEqualTo(1);assertThat(mapper.unassign("TR-1")).isEqualTo(1);
assertThat(mapper.countCandidates(null)).isEqualTo(2);assertThat(mapper.task(1L).getTripNo()).isNull();
}
@Test void pickedCargoCannotBeReleasedBackToPendingByCancellationSql() {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)");
assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");
}
}
@@ -0,0 +1,106 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.lastleg.fleet.driver.*;
import com.baiyue.lastleg.fleet.reporting.*;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class FleetPickupServiceTest {
FleetPickupRepository repository;FleetTripService trips;FleetPickupService service;
TripOperationsEvidenceGateway files;FleetTripEntity trip;WarehouseTransferOrder source;FleetPickupTask task;
final Map<String,String> commands=new HashMap<>();
@BeforeEach void setup() {
repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class);
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules());
trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L);
source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED");
when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip);
when(repository.source(eq("C1"),eq("WT-1"),anyBoolean())).thenReturn(source);
when(repository.task(1L)).thenReturn(task);when(repository.lockTask(1L)).thenReturn(task);when(repository.tasks("TR-1")).thenAnswer(c->List.of(task));
when(repository.commandHash(eq("TR-1"),anyString())).thenAnswer(c->commands.get(c.getArgument(1)));
doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString());
doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
@Test void pickupMustArriveBeforePdaAndWrongWarehouseCannotRead() {
when(repository.pdaSource("100","WT-1")).thenReturn(source);
assertThatThrownBy(()->service.requireMeasuringSource("100","WT-1")).hasMessageContaining("先完成车辆取货送仓");
task.setStatus("ARRIVED");assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);
assertThatThrownBy(()->service.requireMeasuringSource("200","WT-1")).isInstanceOf(ResponseStatusException.class);
}
@Test void selfDeliveryKeepsOriginalMeasurementPath() {
source.setDeliveryMethod("CUSTOMER_DELIVERY");when(repository.pdaSource("100","WT-1")).thenReturn(source);
assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);verify(repository,never()).task(anyLong());
}
@Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() {
var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1");
source.setDeliveryMethod("CUSTOMER_DELIVERY");assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("不是已审核");
source.setDeliveryMethod("WAREHOUSE_PICKUP");when(repository.task(1L)).thenReturn(null);when(repository.lockTask(1L)).thenReturn(null);
var other=source(2L,"C2","WT-2","200");when(repository.source("C2","WT-2",true)).thenReturn(other);
var mixed=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1"),new FleetPickupModels.Source("C2","WT-2")),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-2");
assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).create(anyString(),any());
var duplicate=new FleetPickupModels.Create(List.of(plan.sources().get(0),plan.sources().get(0)),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-3");
assertThatThrownBy(()->service.create("OP",duplicate)).hasMessageContaining("重复选择");
}
@Test void alreadyScheduledSourceIsNotScheduledAgain() {
var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1");
assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).create(anyString(),any());
}
@Test void pickupQuantityDoesNotOverwriteForecastAndReplayDoesNotWriteTwice() {
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货现场.jpg","image/jpeg",500));
var request=new FleetPickupModels.Pickup(2L,"pickup-key-1",7,List.of(88L),"现场多提两箱,等待仓库核实");
var result=service.picked("TR-1","C1","WT-1","OP",request);
assertThat(task.getActualPackages()).isEqualTo(7);assertThat(source.getPackageCount()).isEqualTo(5);assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
assertThat(result.get(0).status()).isEqualTo("PICKED");assertThat(result.get(0).evidence()).hasSize(1);
service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
var changed=new FleetPickupModels.Pickup(2L,"pickup-key-1",9,List.of(88L),null);
assertThatThrownBy(()->service.picked("TR-1","C1","WT-1","OP",changed)).hasMessageContaining("不能提交不同");
}
@Test void evidenceCannotCrossCustomerEvenWithinTheSameTrip() {
assertThatThrownBy(()->service.picked("TR-1","C1","WT-1","OP",new FleetPickupModels.Pickup(2L,"pickup-key-1",5,List.of(99L),null))).hasMessageContaining("不属于当前订单");
assertThatThrownBy(()->service.evidence("C1","WT-1",99L)).hasMessageContaining("没有该取货凭证");
verifyNoInteractions(files);verify(repository,never()).update(any());
}
@Test void cannotArriveBeforePickupAndCannotCompleteWithPendingMembers() {
var command=new FleetPickupModels.Command(2L,"arrive-key-1","确认送仓");
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",command)).hasMessageContaining("先确认该单已取货");
assertThatThrownBy(()->service.complete("TR-1","OP",command)).hasMessageContaining("全部取货订单");
}
@Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() {
task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC));
service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台"));
assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓"));
assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
}
@Test void staleVersionAndWrongTripPurposeAreRejected() {
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化");
trip.setTripPurpose("DELIVERY");assertThatThrownBy(()->service.tasks("TR-1")).hasMessageContaining("不是入仓取货");
}
/** 车次展示的资源不能代替真实占用事实。 */
@Test void departureRequiresBothResourcesAndTheirCurrentOwnership() {
trip.setStatus("PLANNED");
var command=new FleetPickupModels.Command(2L,"depart-key-1","开始取货");
assertThatThrownBy(()->service.depart("TR-1","OP",command)).hasMessageContaining("先分配车辆和司机");
trip.setVehicleId(20L);trip.setDriverId(30L);
when(repository.ownsResources(9L,20L,30L)).thenReturn(false);
assertThatThrownBy(()->service.depart("TR-1","OP",command)).hasMessageContaining("占用与当前车次不一致");
assertThat(trip.getStatus()).isEqualTo("PLANNED");
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
/** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */
static WarehouseTransferOrder source(long id,String customer,String number,String warehouse) {
var row=new WarehouseTransferOrder();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number);
row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5);
row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row;
}
}
@@ -186,8 +186,8 @@ class FleetTripPersistenceTest {
ReflectionTestUtils.setField(dispatchGateway, "deliveryExecution", deliveryExecution);
ReflectionTestUtils.setField(dispatchGateway, "cargoSources", new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(session.getMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class))));
service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json,
bindingMapper, dispatchGateway, reservations), FleetTripService.class);
service = transactional(new FleetTripService(new FleetTripRepository(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), bindingMapper), vehicles, drivers, directory, json,
dispatchGateway, reservations), FleetTripService.class);
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
planning=mock(FleetDispatchPlanningGateway.class);
ReflectionTestUtils.setField(service,"planning",planning);
@@ -368,6 +368,25 @@ class FleetTripPersistenceTest {
assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle));
}
/** 下拉占用来自真实资源表,本车可保留、别车不可选,查询不预留资源。 */
@Test void candidateOccupancySeparatesResourceKindAndReleasesAfterCancellation() {
long occupiedVehicle=vehicle(true), freeVehicle=vehicle(true), occupiedDriver=driver(true);
String first=trip(), second=trip();
service.assign(first,"1",new FleetTripModels.Assign(0L,occupiedVehicle,occupiedDriver,"候选占用验收"));
var query=new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedVehicle,freeVehicle,occupiedVehicle));
var other=service.resourceOccupancy(second,query);
assertThat(other).hasSize(2);
assertThat(other.get(0)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(occupiedVehicle),first,false));
assertThat(other.get(1)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(freeVehicle),null,true));
assertThat(service.resourceOccupancy(first,query).get(0).available()).isTrue();
assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("DRIVER",List.of(occupiedDriver))).get(0).available()).isFalse();
assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedDriver))).get(0).available()).isTrue();
assertThatThrownBy(()->service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of()))).hasMessageContaining("有效的车辆或司机候选");
service.cancel(first,"1",new FleetTripModels.Cancel(1L,"取消释放候选资源"));
assertThat(service.resourceOccupancy(second,query)).allMatch(FleetTripModels.ResourceOccupancy::available);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero();
}
@Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() {
long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true);
long oldDriver = driver(true), occupiedDriver = driver(true);
@@ -26,6 +26,9 @@ class WarehouseTransferCartonCountTest {
@BeforeEach void setup() {
service = spy(new WarehouseTransferService());
ReflectionTestUtils.setField(service, "transfers", orders);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> orders.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferCartons", cartons);
ReflectionTestUtils.setField(service, "transferItems", items);
ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
@@ -231,6 +231,9 @@ class WarehouseTransferServiceTest {
return work.apply(new WarehouseTransferReceiptCoordinator.Context(target, 51L, 61L));
});
ReflectionTestUtils.setField(service, "transfers", transfers);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> transfers.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);