feat(appointments): support scoped receiving and stable trip continuation

This commit is contained in:
2026-10-16 15:43:04 +08:00
parent 4e2ea89403
commit 30f48bd926
84 changed files with 4879 additions and 136 deletions
@@ -17,6 +17,12 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTransitEvidenceTest {
@Test void removingOriginalPhysicalItemDelegatesToDispatchAuthority() {
var service=mock(com.baiyue.lastleg.dispatch.service.DispatchService.class);
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"dispatchService",service);
adapter.removeItem("PC-1",41L);
verify(service).removeItem("PC-1",41L);verifyNoMoreInteractions(service);
}
@BeforeAll static void initializeDeliveryMetadata() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "fleet-transit-evidence"),
@@ -72,11 +78,20 @@ class FleetTransitEvidenceTest {
@Test void unsignedReturnRequiresImmutableReturnOperation() {
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(LocalDateTime.now());
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.terminalEvidence("PC-1", false));
var returned = LocalDateTime.of(2026, 10, 3, 10, 0);
when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned);
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 10, returned)));
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
var terminal=adapter.terminalEvidence("PC-1",false);
assertEquals("CUSTOMER-A",terminal.customerId());assertEquals(10,terminal.shippedPackages());
var returnedDelivery=terminal.deliveries().get(0);
assertEquals("FBA:ONT8",returnedDelivery.destinationKey());assertEquals(10,returnedDelivery.packages());
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),returnedDelivery.terminalAt());
assertEquals("RETURNED",returnedDelivery.status());assertEquals(0L,returnedDelivery.recordVersion());
assertEquals(List.of(new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.ReturnedReceipt("51","61",10,
returned.atZone(ZoneId.systemDefault()).toInstant())),returnedDelivery.receipts());
}
@Test void crossCustomerAndUnbalancedFactsFailClosed() {
@@ -54,6 +54,40 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FleetTripPersistenceTest {
@Test void originalBatchReferencesSurviveRemovalReentryAndLaterDispatchReassignment() throws Exception {
String number=assignedTrip();dispatch(11,"CLIENT-A","92");dispatch(12,"CLIENT-B","92");
var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
ReflectionTestUtils.setField(service,"appointmentAllocations",allocations);
var a=List.of(batchReference("1","CLIENT-A",6),batchReference("2","CLIENT-A",4));
var originalB=List.of(batchReference("3","CLIENT-B",10));
var current=new java.util.HashMap<Long,List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference>>();current.put(11L,a);current.put(12L,originalB);
when(allocations.references(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return ids.stream().collect(java.util.stream.Collectors.toMap(id->id,id->current.getOrDefault(id,List.of())));});
service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(1L,"DP-11","加入甲"));
var both=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(2L,"DP-12","加入乙"));
assertThat(both.dispatches().get(0).appointmentBatches()).containsExactlyElementsOf(a);assertThat(both.previousDispatches()).isEmpty();
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(3L,"DP-12","乙后送"));
current.put(12L,List.of(batchReference("4","CLIENT-B",4),batchReference("5","CLIENT-B",6)));
var reopened=service.detail(number);
assertThat(reopened.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A");
assertThat(reopened.previousDispatches()).hasSize(1);
var previous=reopened.previousDispatches().get(0);assertThat(previous.dispatchNo()).isEqualTo("DP-12");assertThat(previous.customerId()).isEqualTo("CLIENT-B");
assertThat(previous.recordVersion()).isEqualTo(3);assertThat(previous.recordedAt()).isNotNull();assertThat(previous.appointmentBatches()).isEqualTo(originalB);
var rebound=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(4L,"DP-12","按当前后送批重入"));
assertThat(rebound.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L));
assertThat(rebound.previousDispatches()).hasSize(1);assertThat(rebound.previousDispatches().get(0).appointmentBatches()).isEqualTo(originalB);
var audit=service.history(number,1,20).records().stream().filter(h->h.recordVersion()==3).findFirst().orElseThrow();
assertThat(audit.snapshot().dispatches().get(1).appointmentBatches()).isEqualTo(originalB);
assertThat(rebound.appointmentHistoryUnavailableReason()).isNull();
var json=new ObjectMapper().findAndRegisterModules();
var incomplete=json.valueToTree(audit.snapshot());((com.fasterxml.jackson.databind.node.ObjectNode)incomplete.path("dispatches").get(1)).remove("appointmentBatches");
jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=(SELECT id FROM ll_fleet_trip WHERE trip_no=?) AND record_version=3",json.writeValueAsString(incomplete),number);
var readable=service.detail(number);assertThat(readable.dispatches()).hasSize(2);assertThat(readable.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L));
assertThat(readable.previousDispatches()).isEmpty();assertThat(readable.appointmentHistoryUnavailableReason()).contains("追踪不完整");
}
private static com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batchReference(String id,String customer,int packages) {
return new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:"+id,"AP-"+id,customer,
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:"+customer,"FBA:ONT8",packages)));
}
private JdbcTemplate jdbc;
private FleetTripService service;
private FleetTripPreparationService preparation;
@@ -11,6 +11,15 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkTransportProviderTest {
@Test void linkedVehicleCarriesIndependentCargoAndCommonPlanVersions() {
var mapper=mock(AppointmentWorkTripMapper.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setId(10L);trip.setTripNo("TR-10");
when(mapper.trip(10)).thenReturn(trip);when(commitments.impactToken("TRIP:10")).thenReturn("TRIP:10:2");
when(commitments.impactToken("TRIP_PLAN:10")).thenReturn("TRIP_PLAN:10:0");
var request=new FactRequest("W",Type.TRANSPORT,Flow.TRANSFER,"C",null,null,"10",List.of(),List.of(),List.of());
var fact=new AppointmentWorkTransportProvider(mapper,mock(FleetWarehouseClient.class),commitments,"token").read(List.of(request)).get(0);
assertEquals("TRIP:10:2",fact.impactToken());assertEquals("TRIP_PLAN:10:0",fact.dependentImpactToken());
}
@Test void unassignedTransportWorkDoesNotQueryTripsOrWarehousesPerSource() {
var mapper=mock(AppointmentWorkTripMapper.class);var warehouses=mock(FleetWarehouseClient.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var requests=IntStream.range(0,200).mapToObj(i->new FactRequest("W"+i,Type.TRANSPORT,Flow.TRANSFER,"C"+i,"INBOUND:"+i,null,null,List.of(),List.of(),List.of())).toList();
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkTripChangesTest {
@Test void membershipAndRouteChangesReviewVehicleWhileOnlyCommonPlanChangesAffectOtherAppointments() {
var rows=new HashMap<String,AppointmentWorkMapper.CommitmentRow>();
var persistence=mock(AppointmentWorkMapper.class);
when(persistence.lockCommitment(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
when(persistence.commitment(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
when(persistence.insertCommitment(anyString(),anyString())).thenAnswer(c->{
var row=new AppointmentWorkMapper.CommitmentRow();row.setEntityKey(c.getArgument(0));row.setStateJson(c.getArgument(1));
rows.put(row.getEntityKey(),row);return 1;
});
when(persistence.updateCommitment(anyString(),anyString(),anyLong())).thenAnswer(c->{
var row=rows.get(c.getArgument(0));row.setStateJson(c.getArgument(1));row.setImpactVersion(c.getArgument(2));return 1;
});
var commitments=new AppointmentWorkCommitmentService(persistence,new ObjectMapper().findAndRegisterModules());
var mapper=mock(AppointmentWorkTripMapper.class);
var changes=new AppointmentWorkTripChanges(mock(AppointmentWorkChanges.class),commitments,mapper);
var trip=new FleetTripEntity();trip.setId(10L);trip.setVehicleId(20L);trip.setDriverId(30L);trip.setStatus("PLANNED");
trip.setStopsJson("[A,B,C]");trip.setPlannedDepartureAt(LocalDateTime.parse("2026-10-17T10:00:00"));
trip.setExpectedDriverEndAt(LocalDateTime.parse("2026-10-17T20:00:00"));
when(mapper.batches(10)).thenReturn(List.of("A","B","C"));changes.changed(trip);
assertEquals("TRIP:10:0",commitments.impactToken("TRIP:10"));assertEquals("TRIP_PLAN:10:0",commitments.impactToken("TRIP_PLAN:10"));
when(mapper.batches(10)).thenReturn(List.of("C","B","A"));changes.changed(trip);
assertEquals("TRIP:10:0",commitments.impactToken("TRIP:10"));
when(mapper.batches(10)).thenReturn(List.of("A","C"));trip.setStopsJson("[A,C]");changes.changed(trip);
assertEquals("TRIP:10:1",commitments.impactToken("TRIP:10"));assertEquals("TRIP_PLAN:10:0",commitments.impactToken("TRIP_PLAN:10"));
changes.changed(trip);assertEquals("TRIP:10:1",commitments.impactToken("TRIP:10"));
trip.setVehicleId(21L);changes.changed(trip);assertEquals("TRIP_PLAN:10:1",commitments.impactToken("TRIP_PLAN:10"));
trip.setDriverId(31L);changes.changed(trip);assertEquals("TRIP_PLAN:10:2",commitments.impactToken("TRIP_PLAN:10"));
trip.setPlannedDepartureAt(trip.getPlannedDepartureAt().plusHours(1));changes.changed(trip);
assertEquals("TRIP_PLAN:10:3",commitments.impactToken("TRIP_PLAN:10"));
trip.setStatus("CANCELLED");changes.changed(trip);assertEquals("TRIP_PLAN:10:4",commitments.impactToken("TRIP_PLAN:10"));
}
}