fix(lastleg): repair dispatch cancellation and return execution contracts

This commit is contained in:
2026-10-18 16:34:14 +08:00
parent 66a0beb653
commit aa33514ec6
64 changed files with 2061 additions and 213 deletions
@@ -17,7 +17,7 @@ class FleetBookingFreightTest {
final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json);
final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json);
FleetBookingEntity booking;
final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:ONT8");
final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 12, "FBA:ONT8");
@BeforeEach void setup() throws Exception {
booking = new FleetBookingEntity();
booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1");
@@ -29,10 +29,10 @@ class FleetBookingFreightTest {
booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt(
new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1",
List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12),
List.of(new FleetBookingModels.PalletSpaces("501", 2)), "OP-1", Instant.parse("2026-10-01T00:00:00Z"))));
List.of(new FleetBookingModels.PalletSpaces("501", 2, null, null)), "OP-1", Instant.parse("2026-10-01T00:00:00Z"))));
var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L);
when(bindings.findByDispatchNo("DP-1")).thenReturn(binding);
when(bookings.incomingForFreight("WT-1")).thenReturn(booking);
when(bookings.forInboundSource(anyString(), eq("IN-1"))).thenReturn(booking);
}
@Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() {
var preview = service.preview(3, "DP-1", "C-1", List.of(source));
@@ -53,9 +53,9 @@ class FleetBookingFreightTest {
@Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() {
var before = service.preview(3, "DP-1", "C-1", List.of(source));
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 11, "FBA:ONT8"))));
new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 11, "FBA:ONT8"))));
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:LAX9"))));
new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 12, "FBA:LAX9"))));
booking.setRecordVersion(5L);
assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash());
}
@@ -127,13 +127,18 @@ class FleetTripPersistenceTest {
for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history",
"ll_fleet_capacity_offer", "ll_fleet_capacity_offer_command", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation",
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
"ll_outbound_pallet_item", "ll_return_carton_stock_receipt", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue();
jdbc.execute(match.group().replace(" JSON", " LONGTEXT")
jdbc.execute(match.group().replace(" JSON", " LONGTEXT").replaceAll("(?i) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", "")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)");
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))");
var configuration = new MybatisConfiguration();
configuration.addMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class);
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, FleetCapacityOfferMapper.class, FleetCapacityOfferCommandMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class,
DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper);
@@ -161,6 +166,9 @@ class FleetTripPersistenceTest {
var bindingMapper = session.getMapper(FleetTripDispatchMapper.class);
bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class);
var workflow = new DispatchPdaWorkflowService();
var deliveryExecution = new com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class)));
ReflectionTestUtils.setField(workflow, "deliveryExecution", deliveryExecution);
ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class));
ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class));
@@ -175,6 +183,9 @@ class FleetTripPersistenceTest {
var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class),
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class),
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
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);
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
@@ -530,6 +541,10 @@ class FleetTripPersistenceTest {
var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null));
var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0);
when(incoming.create(eq("1"),any())).thenReturn(awaiting);
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('9','WT-EXT','WT-EXT')");
jdbc.update("INSERT INTO ll_inventory_position VALUES(9001,'9',902,'92',9002,22)");
jdbc.update("INSERT INTO ll_inventory_balance VALUES(9002,'9',9003,'WT-EXT')");
jdbc.update("INSERT INTO ll_order VALUES(9003,'9','WAREHOUSE_TRANSFER',NULL)");
var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start");
var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request);
assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue();
@@ -540,11 +555,11 @@ class FleetTripPersistenceTest {
when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual);
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual);
assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成");
List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成");
assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00");
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages()));
var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"));
List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt"));
assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22);
assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00");
assertThat(accepted.booking().quoteSource()).isEqualTo(source);
@@ -568,6 +583,33 @@ class FleetTripPersistenceTest {
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联"));
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED");
assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull();
// 解除关联后重新核对已有实收,不创建新库存,也不重复预留承运容量。
var reopenedPlan=execution.capacityPlan(number);
var refreshed=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(
reopenedPlan.recordVersion(),reopenedPlan.manifestHash(),List.of(measurement()),"解除关联后重新核对"));
var currentBooking=bookingService.list(number).records().get(0);
var receiptAgain=new FleetBookingModels.AcceptReceipt(refreshed.recordVersion(),currentBooking.recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt-again");
var renewed=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain);
assertThat(renewed.booking().status()).isEqualTo("RECEIVED");
assertThat(renewed.booking().cargo().packageCount()).isEqualTo(22);
assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain).replayed()).isTrue();
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
// 实物换托后源摘要变化且旧测量失效;缺测量拒绝,显式实测后更新快照及容量。
var moved=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","READY_FOR_DISPATCH","moved-receipt",
List.of(new FleetBookingIncomingGateway.Pallet("903","PALLET-903",22,null,null)),22);
when(incoming.inspect("9","WT-EXT",true)).thenReturn(moved);
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(moved);
var withoutMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("903",1,null,null)),new BigDecimal("55.00"),"moved-confirm");
assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withoutMeasure)).hasMessageContaining("实际托盘");
var withMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("903",1,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-confirm");
var measuredAgain=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure);
assertThat(measuredAgain.booking().cargo().weightKg()).isEqualByComparingTo("170");
assertThat(measuredAgain.booking().cargo().volumeCbm()).isEqualByComparingTo("1.7");
assertThat(measuredAgain.booking().receipt().source().pallets()).singleElement().satisfies(p->{assertThat(p.warehousePalletId()).isEqualTo("903");assertThat(p.weightKg()).isEqualByComparingTo("170");});
assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure).replayed()).isTrue();
}
@Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() {
@@ -931,6 +973,31 @@ class FleetTripPersistenceTest {
} finally { executor.shutdownNow(); }
}
/** 取消与实际整车发车并发时,已拣货车只能发车;出库和派送不得产生取消终态。 */
@Test void outboundCancellationRacingDepartureCannotCreateDepartedCancelledCargo() throws Exception {
String number=readyTrip();var loaded=confirm(number);
var cancel=new com.baiyue.lastleg.delivery.service.DeliveryTransferService();
var config=new MybatisConfiguration();config.addMapper(OutboundOrderMapper.class);config.addMapper(DispatchOrderMapper.class);
var source=(javax.sql.DataSource)jdbc.getDataSource();
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(source);factory.setConfiguration(config);factory.afterPropertiesSet();
var data=new SqlSessionTemplate(factory.getObject());
ReflectionTestUtils.setField(cancel,"outboundOrders",data.getMapper(OutboundOrderMapper.class));
ReflectionTestUtils.setField(cancel,"dispatchOrders",data.getMapper(DispatchOrderMapper.class));
ReflectionTestUtils.setField(cancel,"fleetBindings",bindingGuard);
var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2);
try {
var departure=executor.submit(()->{start.await(5,TimeUnit.SECONDS);return execution.depart("92","DP-11","1",new FleetExecutionModels.Depart(loaded.recordVersion(),"race-depart","并发取消验证"));});
var cancellation=executor.submit(()->{start.await(5,TimeUnit.SECONDS);try{
new org.springframework.transaction.support.TransactionTemplate(transactions).executeWithoutResult(s->cancel.cancelOutbound("OUT-11","1",new com.baiyue.lastleg.delivery.dto.DeliveryRequests.Cancel("并发取消")));
return false;
}catch(ResponseStatusException rejected){return rejected.getStatusCode().value()==409;}});
start.countDown();assertThat(departure.get(20,TimeUnit.SECONDS).shippedPackages()).isEqualTo(20);assertThat(cancellation.get(20,TimeUnit.SECONDS)).isTrue();
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsOnly("OUTBOUNDED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE status='CANCELLED'",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'",Integer.class)).isEqualTo(2);
} finally {executor.shutdownNow();}
}
@Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() {
String number = readyTrip(); var confirmed = confirm(number);
long driverId = Long.parseLong(service.detail(number).trip().driverId());
@@ -1125,6 +1192,8 @@ class FleetTripPersistenceTest {
dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId);
jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)",
dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0));
jdbc.update("UPDATE ll_outbound_order SET package_count=10 WHERE id=?", dispatchId + 1000);
jdbc.update("UPDATE ll_delivery_order SET package_count=10 WHERE id=?", dispatchId + 2000);
}
private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception {