feat: complete phase 5 overseas fulfillment contracts

This commit is contained in:
2026-09-01 14:24:59 +08:00
parent ffed9bf898
commit 97168b19d8
46 changed files with 2446 additions and 409 deletions
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.delivery;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Phase5PdaDeploymentSqlContractTest {
@Test
void overseasUnifiedMigrationConvergesPhase5IdentifiersWithoutBackfill() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
int start = sql.indexOf("-- 五期排车、出库与海外PDA正式业务号/幂等约束");
int end = sql.indexOf("USE baiyue_lastleg;", start);
assertTrue(start >= 0 && end > start);
String phase5 = sql.substring(start, end);
assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_migratable"));
assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_ready"));
assertTrue(phase5.contains("warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0'"));
assertTrue(phase5.contains("SIGNAL SQLSTATE '45000'"));
assertTrue(phase5.contains("MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL"));
assertTrue(phase5.contains("ADD COLUMN completion_idempotency_key VARCHAR(255) NULL"));
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)"));
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)"));
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)"));
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)"));
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)"));
assertFalse(Pattern.compile("(?im)^\\s*UPDATE\\s+").matcher(phase5).find());
assertFalse(phase5.contains("JOIN ll_dispatch_order"));
assertFalse(sql.contains("uk_ll_dispatch_order_no(customer_id,dispatch_no)"));
assertFalse(sql.contains("uk_ll_outbound_no(customer_id,outbound_no)"));
assertFalse(sql.contains("uk_ll_dispatch_pda_task_no(customer_id,task_no)"));
assertFalse(sql.contains("uk_ll_outbound_pda_task_no(customer_id,task_no)"));
}
}
@@ -0,0 +1,166 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PlatformDispatchOutboundContractTest {
@Test
void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception {
Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class,
String.class, DispatchRequests.Update.class);
Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class);
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
String.class, DeliveryRequests.Transfer.class);
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
AuthenticatedUser.class, String.class, DeliveryRequests.PalletQuery.class);
Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound",
AuthenticatedUser.class, String.class, DeliveryRequests.Cancel.class);
assertPermission(update, OverseasPermissions.DISPATCH_EDIT);
assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL);
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) {
assertTrue(Arrays.stream(method.getParameters())
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
}
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
.noneMatch(method -> "depart".equals(method.getName())));
assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", "route",
"fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"),
Arrays.stream(DispatchRequests.Update.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
}
@Test
void oneClickRequiresCustomerWhileOutboundListKeepsOptionalFilter() throws Exception {
Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class,
String.class, DeliveryRequests.OneClickOutbound.class);
Method page = DeliveryTransferController.class.getMethod("outboundPage", AuthenticatedUser.class,
String.class, DeliveryRequests.Query.class);
assertPermission(create, OverseasPermissions.OUTBOUND_CREATE);
assertPermission(page, OverseasPermissions.OUTBOUND_LIST);
RequestParam createCustomer = create.getParameters()[1].getAnnotation(RequestParam.class);
RequestParam pageCustomer = page.getParameters()[1].getAnnotation(RequestParam.class);
assertNotNull(createCustomer);
assertTrue(createCustomer.required());
assertNotNull(pageCustomer);
assertFalse(pageCustomer.required());
Method deliveryPage = DeliveryTransferController.class.getMethod("deliveryPage", AuthenticatedUser.class,
String.class, DeliveryRequests.Query.class);
RequestParam deliveryCustomer = deliveryPage.getParameters()[1].getAnnotation(RequestParam.class);
assertNotNull(deliveryCustomer);
assertFalse(deliveryCustomer.required());
}
@Test
void platformActionsRejectMerchantIdentityBeforeBusinessLookup() {
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant",
"MERCHANT", "WEB", Set.of());
DispatchController dispatchController = new DispatchController();
DeliveryTransferController deliveryController = new DeliveryTransferController();
ResponseStatusException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows(
ResponseStatusException.class,
() -> dispatchController.cancel(merchant, "DSP-101"));
ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows(
ResponseStatusException.class,
() -> deliveryController.outboundDetail(merchant, "OUT-101",
new DeliveryRequests.PalletQuery(1, 20, null)));
assertEquals(403, dispatchRejected.getStatusCode().value());
assertEquals(403, outboundRejected.getStatusCode().value());
}
@Test
void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception {
String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class);
String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class);
String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class);
String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class);
for (String sql : Set.of(dispatchFind, dispatchLock, outboundFind, outboundLock)) {
assertFalse(sql.contains("customer_id"));
}
assertTrue(dispatchLock.contains("FOR UPDATE"));
assertTrue(outboundLock.contains("FOR UPDATE"));
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
assertTrue(schema.contains("UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no)"));
assertTrue(schema.contains("UNIQUE KEY uk_ll_outbound_no (outbound_no)"));
assertFalse(schema.contains("uk_ll_dispatch_order_no (customer_id,dispatch_no)"));
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
}
@Test
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:",
" /api/platform/v1/last-leg/dispatches/candidates:");
String transfer = slice(yaml,
" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:",
" /api/platform/v1/last-leg/outbound-orders:");
String outboundDetail = slice(yaml,
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}:",
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:");
String outboundCancel = slice(yaml,
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:",
" /api/platform/v1/last-leg/overseas-procurements:");
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
assertFalse(action.contains("name: customerId"));
}
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
}
private static void assertPermission(Method method, String permission) {
PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
assertNotNull(annotation);
assertEquals("hasAuthority('" + permission + "')", annotation.value());
}
private static String select(Class<?> mapper, String method, Class<?>... parameterTypes) throws Exception {
Select annotation = mapper.getMethod(method, parameterTypes).getAnnotation(Select.class);
assertNotNull(annotation);
return String.join("", annotation.value());
}
private static String slice(String source, String start, String end) {
int from = source.indexOf(start);
int to = source.indexOf(end, from + start.length());
assertTrue(from >= 0 && to > from);
return source.substring(from, to);
}
}
@@ -0,0 +1,139 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class MerchantDeliveryControllerTest {
private final DeliveryTransferService service = mock(DeliveryTransferService.class);
private final MerchantDeliveryController controller = controller();
private final DeliveryRequests.Query query = new DeliveryRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception {
RequestMapping mapping = MerchantDeliveryController.class.getAnnotation(RequestMapping.class);
assertEquals(List.of("/api/merchant/v1/last-leg/delivery-orders"), List.of(mapping.value()));
Method page = MerchantDeliveryController.class.getMethod("page", AuthenticatedUser.class,
DeliveryRequests.Query.class);
Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class,
String.class, DeliveryRequests.Transfer.class);
Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class);
assertPermission(page, OverseasPermissions.MERCHANT_DELIVERY_LIST);
assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST);
assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE);
assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST,
OverseasPermissions.MERCHANT_DELIVERY_CREATE);
assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter));
assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter));
assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter));
assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents())
.noneMatch(component -> "customerId".equals(component.getName())));
}
@Test
void allOperationsUseOnlyAuthenticatedCustomerScope() {
AuthenticatedUser merchant = user(100L, "MERCHANT");
DeliveryRequests.Transfer request = transferRequest();
controller.page(merchant, query);
controller.detail(merchant, "DEL-100");
controller.create(merchant, "DSP-100", request);
verify(service).deliveryPage("100", query);
verify(service).deliveryDetail("100", "DEL-100");
verify(service).merchantTransfer("100", "DSP-100", "1", request);
}
@Test
void merchantEndpointsRejectPlatformIdentity() {
AuthenticatedUser platform = user(0L, "PLATFORM");
assertThrows(ResponseStatusException.class, () -> controller.page(platform, query));
assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100"));
assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest()));
}
@Test
void invalidCreatePayloadIsRejectedByBeanValidationContract() {
DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null,
" ", " ", null, null, null, null, null, null, null, null, null);
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertTrue(factory.getValidator().validate(invalid).size() >= 6);
}
}
@Test
void openApiPublishesOnlyTokenScopedMerchantDeliveryProtocol() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String contract = slice(yaml, " /api/merchant/v1/last-leg/delivery-orders:",
" /api/merchant/v1/last-leg/overseas-procurements:");
assertTrue(contract.contains("merchant:warehouse:delivery:list"));
assertTrue(contract.contains("merchant:warehouse:delivery:create"));
assertFalse(contract.contains("name: customerId"));
}
private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) {
RequestParam annotation = parameter.getAnnotation(RequestParam.class);
return annotation != null && ("customerId".equals(annotation.name())
|| "customerId".equals(annotation.value()));
}
private static void assertPermission(Method method, String permission) {
PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
assertEquals("hasAuthority('" + permission + "')", annotation.value());
}
private static String slice(String source, String start, String end) {
int from = source.indexOf(start);
int to = source.indexOf(end, from + start.length());
assertTrue(from >= 0 && to > from);
return source.substring(from, to);
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of());
}
private MerchantDeliveryController controller() {
MerchantDeliveryController value = new MerchantDeliveryController();
ReflectionTestUtils.setField(value, "service", service);
return value;
}
private static DeliveryRequests.Transfer transferRequest() {
return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null,
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null,
List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null)));
}
}
@@ -6,11 +6,14 @@ import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
@@ -23,6 +26,14 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -30,6 +41,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
@@ -59,6 +71,12 @@ class DeliveryTransferServiceTest {
@Mock LastlegOrderMapper tailOrders;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderDetailMapper tailOrderDetails;
@Mock OutboundPdaTaskMapper pdaTasks;
@Mock OutboundPdaPickMapper pdaPicks;
@Mock InventoryPositionMapper inventoryPositions;
@Mock InventoryBalanceMapper inventoryBalances;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipCartonMapper dropshipCartons;
private DeliveryTransferService service;
@@ -78,19 +96,24 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails);
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
ReflectionTestUtils.setField(service, "pdaPicks", pdaPicks);
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
}
@Test
void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12),
item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8)
));
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
transferRequest("transfer-101", "ready"));
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
@@ -122,12 +145,11 @@ class DeliveryTransferServiceTest {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
transferRequest(" transfer-101 ", null));
assertTrue(result.replayed());
@@ -142,12 +164,11 @@ class DeliveryTransferServiceTest {
void idempotencyKeyCannotBeReusedForAnotherDispatch() {
DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A");
OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch);
when(dispatchOrders.lockGlobalByDispatchNo("DSP-102")).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-102", "U-1", transferRequest("shared-key", null)));
service.platformTransfer("DSP-102", "U-1", transferRequest("shared-key", null)));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).insert((OutboundOrder) any());
@@ -158,15 +179,14 @@ class DeliveryTransferServiceTest {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, winner, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
transferRequest("transfer-101", null));
assertTrue(result.replayed());
@@ -183,12 +203,11 @@ class DeliveryTransferServiceTest {
source.setOrderId(901L);
LastlegOrder tailOrder = new LastlegOrder();
tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source));
when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder));
service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("direct-order", null));
service.platformTransfer("DSP-101", "U-1", transferRequest("direct-order", null));
assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus());
assertNotNull(tailOrder.getOutboundOrderNo());
@@ -204,12 +223,11 @@ class DeliveryTransferServiceTest {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12);
planned.setSourceStage("PLAN");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("transfer-101", null)));
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null)));
assertEquals(409, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("received"));
@@ -220,10 +238,10 @@ class DeliveryTransferServiceTest {
void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301",
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
assertEquals("OUT-301", detail.outbound().outboundNo());
@@ -234,16 +252,216 @@ class DeliveryTransferServiceTest {
}
@Test
void detailLookupCannotCrossTenantBoundary() {
when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null);
void globalOutboundDetailUsesTheCustomerResolvedFromTheBusinessNumber() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
assertEquals("OUT-301", detail.outbound().outboundNo());
verify(deliveryOrders).lockByOutbound("TENANT-A", 301L);
}
@Test
void merchantTransferCannotResolveAnotherCustomersDispatch() {
when(dispatchOrders.findByDispatchNo("TENANT-B", "DSP-101")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)));
service.merchantTransfer("TENANT-B", "DSP-101", "U-2", transferRequest("merchant-key", null)));
assertEquals(404, exception.getStatusCode().value());
verify(outboundOrders).findByNo("TENANT-B", "OUT-301");
verify(deliveryOrders, never()).lockByOutbound(any(), any());
verify(outboundPallets, never()).selectPage(any(), any());
verify(dispatchOrders, never()).lockGlobalByDispatchNo(any());
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "merchant-key")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2",
transferRequest(" merchant-key ", null));
assertTrue(result.replayed());
assertEquals("DEL-401", result.deliveryNo());
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void merchantTransferRejectsCancelledDispatch() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
dispatch.setStatus("CANCELLED");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest("merchant-key", null)));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void merchantDeliveryDetailCannotCrossCustomerBoundary() {
when(deliveryOrders.findByNo("TENANT-B", "DEL-401")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.deliveryDetail("TENANT-B", "DEL-401"));
assertEquals(404, exception.getStatusCode().value());
verify(dispatchItems, never()).findByDispatchOrder(any(), any());
}
@Test
void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setStatus("WAIT_PICK");
OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
item.setStatus("WAIT_PICK"); item.setPickedPackageCount(0);
OutboundPdaTask task = new OutboundPdaTask();
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setStatus("PENDING");
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task);
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("cancelled by platform"));
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("duplicate command"));
assertEquals("CANCELLED", outbound.getStatus());
assertEquals("CANCELLED", item.getStatus());
assertEquals("CANCELLED", task.getStatus());
verify(outboundPallets, org.mockito.Mockito.times(1)).findByOutbound("TENANT-A", 301L);
}
@Test
void globalOutboundCancelRejectsAnExecutedOrderWithoutDrift() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-key");
outbound.setStatus("PICKED");
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("too late")));
assertEquals(409, exception.getStatusCode().value());
assertEquals("PICKED", outbound.getStatus());
verify(outboundOrders, never()).updateById(any(OutboundOrder.class));
verify(outboundPallets, never()).findByOutbound(any(), any());
}
@Test
void outboundPdaListAndDetailUseOnlyBoundWarehouse() throws Exception {
assertNotNull(DeliveryTransferService.class.getMethod("pdaPick", String.class, String.class,
String.class, DeliveryRequests.PdaPick.class).getAnnotation(Transactional.class));
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-1");
OutboundPdaTask task = new OutboundPdaTask();
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
task.setTaskNo("PDA-701"); task.setStatus("PENDING");
when(pdaTasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task));
when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of());
assertEquals(1, service.pdaTasks("WH-1", null).size());
assertEquals("PDA-701", service.pdaTaskDetail("WH-1", "PDA-701").task().taskNo());
verify(pdaTasks).listByWarehouse("WH-1", null);
}
@Test
void outboundPdaDetailReturnsEveryPickablePositionWithStableBusinessCodes() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-1");
OutboundPdaTask task = new OutboundPdaTask();
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2);
item.setSkuCode("SKU-1"); item.setInboundCode("IN-A");
InventoryPosition position = new InventoryPosition();
position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L);
position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("PLT-501");
position.setAvailablePackages(2L); position.setPickedPackages(0L);
InventoryBalance balance = new InventoryBalance();
balance.setId(801L); balance.setCustomerId("TENANT-A"); balance.setOverseasWarehouseId("WH-1");
balance.setSkuCode("SKU-1"); balance.setInboundCode("IN-A");
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A");
DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B");
when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position));
when(inventoryBalances.selectById(801L)).thenReturn(balance);
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second));
DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701");
assertEquals(2, detail.items().get(0).positions().size());
assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream()
.map(DeliveryResponses.PdaPickPosition::cartonMark).toList());
DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0);
assertEquals(901L, projected.inventoryPositionId());
assertEquals("PLT-501", projected.palletNo());
assertEquals("SKU-1", projected.skuCode());
assertEquals("IN-A", projected.inboundCode());
assertEquals(1L, projected.availablePackages());
}
@Test
void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-1");
OutboundPdaTask task = new OutboundPdaTask();
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
task.setTaskNo("PDA-701"); task.setStatus("IN_PROGRESS");
com.baiyue.lastleg.delivery.entity.OutboundPdaPick replay = new com.baiyue.lastleg.delivery.entity.OutboundPdaPick();
replay.setTaskId(701L); replay.setOutboundPalletItemId(801L); replay.setInventoryPositionId(901L);
replay.setPackages(2L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(pdaPicks.findByKey("TENANT-A", "pick-1")).thenReturn(replay);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
service.pdaPick("WH-1", "7", "PDA-701", new DeliveryRequests.PdaPick(801L, 901L, 2L, "pick-1"));
verify(outboundPallets, never()).selectById(any());
ResponseStatusException conflict = assertThrows(ResponseStatusException.class,
() -> service.pdaPick("WH-1", "7", "PDA-701",
new DeliveryRequests.PdaPick(801L, 901L, 3L, "pick-1")));
assertEquals(409, conflict.getStatusCode().value());
}
@Test
void outboundPdaCompleteRequiresAllPackagesAndReplaysOnlySameKey() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-1");
OutboundPdaTask task = new OutboundPdaTask();
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2);
item.setPickedPackageCount(1);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1")))
.getStatusCode().value());
item.setPickedPackageCount(2);
service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1"));
assertEquals("COMPLETED", task.getStatus());
service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1"));
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-2")))
.getStatusCode().value());
}
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {