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
@@ -24,6 +24,15 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DeliveryFeeCoverageServiceTest {
/** 同一排车明细中的外接货覆盖不能免掉普通来源的应收。 */
@Test void externalFreightCoversOnlyItsOwnInboundEvenWhenAnItemIsShared() {
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 11))));
var preview = service.preview("DP-1");
var selection = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101, 11).sourceHash(), List.of("LOADING_FEE"));
var result = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selection)), "OP", Set.of("IN-A"));
assertThat(result.includedServices()).doesNotContain("DELIVERY_FEE");
assertThat(result.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::inboundCode).containsExactly("IN-B");
}
final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class);
final DispatchItemMapper items = mock(DispatchItemMapper.class);
final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class);
@@ -12,6 +12,34 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class DispatchFeeCargoMapperTest {
/** 原退回实收只重派其已冻结货量,不借同托其他货物放大来源。 */
@Test void returnedReceiptRetainsItsExactSourceInsteadOfBecomingAWholePallet() {
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
item.setReturnReceiptId(701L); item.setSourceStage("RECEIVED"); item.setInboundCode("IN-A");
item.setPackageCount(2); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
assertThat(query.sources(dispatch, java.util.List.of(item))).singleElement().satisfies(source -> {
assertThat(source.inboundCode()).isEqualTo("IN-A"); assertThat(source.packages()).isEqualTo(2);
});
}
/** 展示普通货或外接货均不能改变真实组成;保留同客户、同仓及本次拣货边界。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"IN-A", "IN-B"})
void actualSourceQueryDoesNotDependOnWhichInboundWasChosenForDisplay(String displayInbound) {
jdbc.update("UPDATE ll_dispatch_item SET inbound_code=?,plan_pallet_id=801", displayInbound);
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
item.setInboundCode(displayInbound); item.setPackageCount(20); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
var sources = query.sources(dispatch, java.util.List.of(item));
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::inboundCode).containsExactly("IN-A", "IN-B");
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::packages).containsExactly(10, 10);
item.setPackageCount(19);
assertThatThrownBy(() -> query.sources(dispatch, java.util.List.of(item))).hasMessageContaining("箱数不一致");
}
JdbcTemplate jdbc; DispatchFeeCargoMapper mapper;
@BeforeEach void setup() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
@@ -41,10 +69,10 @@ class DispatchFeeCargoMapperTest {
assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L);
assertThat(mapper.list("202", 1)).isEmpty();
}
@Test void aSelectedPlanPalletCannotBorrowOtherInboundStockOnTheSamePhysicalPallet() {
@Test void aPhysicalSharedPalletIncludesEveryActualSourceRegardlessOfItsDisplayPlan() {
jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801");
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)");
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(901,'101',401)");
assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11");
assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11", "12");
}
}
@@ -8,7 +8,7 @@ class DeliveryStatusPolicyTest {
@Test
void exposesOnlyServerAuthorizedActionsForEachState() {
assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY"))
.containsExactly("START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
.containsExactly("START_DELIVERY", "REJECT_DELIVERY");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING"))
.containsExactly("REJECT_DELIVERY", "MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DeliveryExecutionGuardTest {
/** 直接消费真实仓储访问适配器,状态和数量由生产门禁判定。 */
@Test void departureRequiresCompleteExecutableDeliveriesAndLocksThem() {
var deliveries = mock(DeliveryOrderMapper.class);
var guard = new DeliveryExecutionGuard(new DeliveryExecutionRepository(mock(OutboundOrderMapper.class), deliveries));
var outbound = new OutboundOrder(); outbound.setId(1L); outbound.setCustomerId("C");
outbound.setOutboundNo("OUT-1"); outbound.setPackageCount(2);
var delivery = new DeliveryOrder(); delivery.setCustomerId("C"); delivery.setOutboundOrderId(1L);
delivery.setOutboundNo("OUT-1"); delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(2);
when(deliveries.lockByOutbound("C", 1L)).thenReturn(List.of(delivery));
guard.requireDeparture(outbound);
delivery.setStatus("CANCELLED");
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("已取消");
delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(1);
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("完整覆盖");
delivery.setPackageCount(2); delivery.setCustomerId("OTHER");
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("来源不一致");
when(deliveries.lockByOutbound("C", 1L)).thenReturn(List.of());
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("缺少对应派送单");
verify(deliveries, times(5)).lockByOutbound("C", 1L);
}
}
@@ -26,6 +26,7 @@ class DeliveryReturnEvidencePersistenceTest {
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(20),outbound_order_no VARCHAR(20),business_type VARCHAR(40),delta_packages BIGINT,source_event_id VARCHAR(100),occurred_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_return_carton_stock_receipt(id BIGINT,customer_id VARCHAR(20),source_dispatch_item_id BIGINT,source_outbound_pallet_item_id BIGINT,packages BIGINT,received_at TIMESTAMP,delivery_order_id BIGINT,receipt_no VARCHAR(64))");
jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A',10,20,'OUT-1')");
jdbc.update("INSERT INTO ll_exception_order VALUES(2,'A',1,'EX-1',71)");
jdbc.update("INSERT INTO ll_exception_return_item VALUES(2,'A',21,41)");
@@ -134,6 +134,16 @@ class DeliveryTransferServiceTest {
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
var actualSources = mock(DispatchCargoSourceQuery.class);
ReflectionTestUtils.setField(service, "cargoSources", actualSources);
org.mockito.Mockito.lenient().when(actualSources.sources(any(), any())).thenAnswer(call -> {
List<DispatchItem> selected = call.getArgument(1);
return selected.stream().map(item -> new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo(
item.getInboundCode(), item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
java.util.Objects.requireNonNullElse(item.getPackageCount(), 0), com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()))).toList();
});
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight);
ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage);