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) {
@@ -35,7 +35,7 @@ class SmartDispatchOpenApiContractTest {
yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds")));
assertTrue(planIdsParameter.contains("required: false"));
String dispatchContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/dispatches:"),
yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:"));
yaml.indexOf("/api/platform/v1/last-leg/outbound-orders:"));
assertFalse(dispatchContract.contains("name: customerId"));
assertFalse(yaml.contains("/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:"));
}
@@ -1,10 +1,12 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
@@ -13,7 +15,10 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@@ -21,21 +26,52 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchPdaWorkflowServiceTest {
@Mock private DispatchOrderMapper orders;
@Mock private DispatchPdaOperationMapper operations;
@Mock private OutboundOrderMapper outboundOrders;
@Mock private DispatchItemMapper items;
@Mock private DispatchPdaTaskMapper tasks;
@Mock private DispatchPdaPickMapper picks;
@Mock DispatchOrderMapper orders;
@Mock OutboundOrderMapper outboundOrders;
@Mock DispatchItemMapper items;
@Mock DispatchPdaTaskMapper tasks;
@Mock DispatchPdaPickMapper picks;
@Mock DispatchPdaOperationMapper operations;
@Mock DispatchPalletLockMapper palletLocks;
@Mock InventoryPositionMapper positions;
@Mock InventoryCommandService inventory;
@Mock DispatchPickOrderAttributionService attribution;
private DispatchPdaWorkflowService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
service = new DispatchPdaWorkflowService();
objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "picks", picks);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "palletLocks", palletLocks);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "pickAttribution", attribution);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@Test
void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() {
@@ -45,126 +81,157 @@ class DispatchPdaWorkflowServiceTest {
}
@Test
void pushRequiresVehiclePlateBeforeCreatingPdaTask() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" ");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() {
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE");
DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING");
when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task));
when(orders.selectById(1L)).thenReturn(order);
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of());
List<DispatchPdaResponses.Task> result = service.list("WH-1", null);
assertEquals(1, result.size());
assertEquals("DSP-1", result.get(0).dispatchNo());
verify(tasks).listByWarehouse("WH-1", null);
}
@Test
void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() {
when(orders.findGlobalByDispatchNo("DSP-1"))
.thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE"));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
() -> service.task("WH-1", "DSP-1"));
assertEquals(409, exception.getStatusCode().value());
assertEquals(404, exception.getStatusCode().value());
verify(tasks, never()).findByDispatch(any(), any());
}
@Test
void pushRequiresWarehouseSelection() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
order.setOverseasWarehouseId("WH-1");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
void exactPickRetryReturnsPersistedResultWithoutInventoryDeduction() throws Exception {
DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING");
order.setVehiclePlate("CA-100");
DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING");
task.setOperatorId("7");
DispatchItem item = item(21L, "100", 5);
DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null);
DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false);
DispatchPdaOperation replay = operation("PICK", request, persisted);
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
when(items.selectById(21L)).thenReturn(item);
when(operations.findKey("100", "pick-1")).thenReturn(replay);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request);
assertEquals(400, exception.getStatusCode().value());
assertEquals(2, result.packages());
verify(inventory, never()).allocate(any(), any(), any());
verify(picks, never()).insert((DispatchPdaPick) any());
}
@Test
void departRequiresVehiclePlate() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" ");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
void completionRejectsPartialPickingWithoutStatusDrift() {
DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING");
order.setVehiclePlate("CA-100");
DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING");
DispatchItem item = item(21L, "100", 6);
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L);
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departRequiresPendingOutboundOrder() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(null);
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departedRequestReplaysAfterStatusChanged() throws Exception {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
DispatchPdaOperation operation = new DispatchPdaOperation();
operation.setOperationType("DEPART"); operation.setStatus("COMPLETED");
operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false)));
when(operations.findKey("100", "depart-1")).thenReturn(operation);
// This exact key/hash pair models the persisted first execution result.
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request);
assertEquals("DEPARTED", result.status());
assertEquals(true, result.replayed());
}
@Test
void completingAnAutomaticallyCompletedTaskReturnsIdempotentSuccess() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "picks", picks);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
DispatchPdaTask task = new DispatchPdaTask();
task.setId(2L); task.setStatus("COMPLETED");
DispatchItem item = new DispatchItem(); item.setPackageCount(6);
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("complete-1", "normal");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "complete-1")).thenReturn(null);
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
when(picks.listTask("100", 2L)).thenReturn(List.of(pick));
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
DispatchPdaResponses.PickingCompleted result = service.completePicking("100", "PC1", request);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1")));
assertEquals("READY_TO_DEPART", result.status());
assertEquals(6, result.pickedPackages());
assertEquals(true, result.replayed());
assertEquals(409, exception.getStatusCode().value());
assertEquals("PICKING", order.getStatus());
verify(orders, never()).updateById((DispatchOrder) any());
}
@Test
void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception {
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
order.setVehiclePlate("CA-100");
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
DispatchItem item = item(21L, "100", 6);
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1");
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request);
assertFalse(first.replayed());
verify(operations).insert((DispatchPdaOperation) any());
DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first);
when(operations.findKey("100", "done-1")).thenReturn(replay);
DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request);
assertTrue(second.replayed());
}
@Test
void departRequiresFullyCompletedPickingAndPendingOutbound() {
DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART");
order.setVehiclePlate("CA-100");
OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND");
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null)));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).updateById((OutboundOrder) any());
}
@Test
void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception {
DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED");
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false);
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted));
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request);
assertEquals("DEPARTED", result.status());
assertTrue(result.replayed());
}
private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception {
DispatchPdaOperation value = new DispatchPdaOperation();
value.setOperationType(type); value.setStatus("COMPLETED");
value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))));
value.setResultJson(objectMapper.writeValueAsString(result));
return value;
}
private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) {
DispatchOrder value = new DispatchOrder();
value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
value.setOverseasWarehouseId(warehouseId); value.setStatus(status);
return value;
}
private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) {
DispatchPdaTask value = new DispatchPdaTask();
value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L);
value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status);
return value;
}
private static DispatchItem item(Long id, String customerId, int packages) {
DispatchItem value = new DispatchItem();
value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L);
value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages);
return value;
}
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
@@ -13,6 +14,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -45,6 +48,7 @@ class DispatchServiceTest {
@Mock DispatchCandidateMapper candidates;
@Mock DispatchDraftMapper drafts;
@Mock DispatchDraftContainerMapper draftContainers;
@Mock DispatchPdaTaskMapper pdaTasks;
private DispatchService service;
@BeforeEach
@@ -56,6 +60,7 @@ class DispatchServiceTest {
ReflectionTestUtils.setField(service, "candidates", candidates);
ReflectionTestUtils.setField(service, "drafts", drafts);
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
}
@@ -73,6 +78,11 @@ class DispatchServiceTest {
assertEquals("ONT8", result.route());
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
verify(orders).insert(dispatch.capture());
ArgumentCaptor<DispatchPdaTask> pdaTask = ArgumentCaptor.forClass(DispatchPdaTask.class);
verify(pdaTasks).insert(pdaTask.capture());
assertEquals(dispatch.getValue().getId(), pdaTask.getValue().getDispatchOrderId());
assertEquals("WH-1", pdaTask.getValue().getWarehouseId());
assertEquals("PDA_PENDING", pdaTask.getValue().getStatus());
assertEquals(1, dispatch.getValue().getPalletCount());
verify(locks).insert(any(DispatchPalletLock.class));
verify(items).insert(any(DispatchItem.class));
@@ -204,20 +214,35 @@ class DispatchServiceTest {
}
@Test
void editCannotBypassPdaManagedDispatchStatus() {
void platformEditResolvesCustomerFromGlobalDispatchNumberAndPreservesStatus() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch);
when(orders.lockById("100", 1L)).thenReturn(dispatch);
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null,
null, null, null, LocalDateTime.now());
DispatchResponse result = service.update("PC1", request);
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
assertEquals("ABC-1", result.vehiclePlate());
verify(orders).updateById(dispatch);
verify(items).findByDispatchOrder("100", 1L);
}
@Test
void platformEditRejectsAnExecutedDispatchWithoutWriting() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
dispatch.setStatus("DEPARTED");
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null,
LocalDateTime.now(), null, null, null, null, "COMPLETED");
null, null, null, null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.update("100", "PC1", request));
() -> service.update("PC1", request));
assertEquals(400, exception.getStatusCode().value());
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
assertEquals(409, exception.getStatusCode().value());
verify(orders, never()).updateById(any(DispatchOrder.class));
}
@@ -255,13 +280,34 @@ class DispatchServiceTest {
void platformDetailUsesCustomerFromMatchedDispatch() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
when(orders.findAcrossCustomersByDispatchNo("PC1")).thenReturn(List.of(dispatch));
when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch);
service.platformDetail("PC1");
verify(items).findByDispatchOrder("200", 1L);
}
@Test
void globalCancelIsIdempotentAndReleasesOnlyTheResolvedCustomerLocks() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
DispatchPalletLock lock = new DispatchPalletLock();
lock.setStatus("ACTIVE"); lock.setActiveKey("PALLET:1");
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
when(locks.selectList(any())).thenReturn(List.of(lock));
service.cancel("PC1");
assertEquals(DispatchService.CANCELLED, dispatch.getStatus());
assertEquals("RELEASED", lock.getStatus());
assertEquals(null, lock.getActiveKey());
verify(locks).updateById(lock);
service.cancel("PC1");
verify(locks, org.mockito.Mockito.times(1)).selectList(any());
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return candidate(planPalletId, warehousePalletId, destination, 12);
}
@@ -4,6 +4,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -32,6 +34,11 @@ class PdaOperationsOpenApiContractTest {
}
}
assertNoServerOwnedFields(PdaInventoryMoveRequest.class);
assertNoServerOwnedFields(DispatchPdaRequests.Pick.class);
assertNoServerOwnedFields(DispatchPdaRequests.CompletePicking.class);
assertNoServerOwnedFields(DispatchPdaRequests.Depart.class);
assertNoServerOwnedFields(DeliveryRequests.PdaPick.class);
assertNoServerOwnedFields(DeliveryRequests.PdaComplete.class);
}
@Test
@@ -56,16 +63,23 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:",
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:");
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:",
"/api/pda/v1/last-leg/dispatches:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:",
"/api/pda/v1/last-leg/outbound-tasks:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
for (String path : paths) assertTrue(yaml.contains(path), path);
assertEquals(19, paths.size());
assertEquals(28, paths.size());
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field);
for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) {
assertFalse(yaml.contains(excluded), excluded);
}
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
}
private static void assertNoServerOwnedFields(Class<?> type) {
@@ -0,0 +1,109 @@
package com.baiyue.lastleg.pda;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.controller.OutboundPdaController;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
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.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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class PdaOutboundDispatchContractTest {
private static final String SECURITY =
"hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')";
@Test
void controllersRequirePdaRoleAndNeverAcceptCustomerOrWarehouseScope() {
assertEquals(SECURITY, DispatchPdaController.class.getAnnotation(PreAuthorize.class).value());
assertEquals(SECURITY, OutboundPdaController.class.getAnnotation(PreAuthorize.class).value());
for (Class<?> controller : Set.of(DispatchPdaController.class, OutboundPdaController.class)) {
for (Method method : controller.getDeclaredMethods()) {
assertTrue(Arrays.stream(method.getParameters()).noneMatch(parameter -> {
RequestParam query = parameter.getAnnotation(RequestParam.class);
return query != null && Set.of("customerId", "warehouseId")
.contains(query.name().isBlank() ? query.value() : query.name());
}));
}
}
assertTrue(Arrays.stream(DispatchPdaRequests.Pick.class.getRecordComponents())
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
assertTrue(Arrays.stream(DeliveryRequests.PdaPick.class.getRecordComponents())
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
}
@Test
void controllersPassOnlyTokenBoundWarehouseToServices() {
DispatchPdaWorkflowService dispatchService = mock(DispatchPdaWorkflowService.class);
DeliveryTransferService outboundService = mock(DeliveryTransferService.class);
DispatchPdaController dispatch = new DispatchPdaController();
OutboundPdaController outbound = new OutboundPdaController();
ReflectionTestUtils.setField(dispatch, "service", dispatchService);
ReflectionTestUtils.setField(outbound, "service", outboundService);
AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L);
dispatch.list(user, null);
outbound.list(user, null);
verify(dispatchService).list("900", null);
verify(outboundService).pdaTasks("900", null);
}
@Test
void missingRolePermissionOrWarehouseBindingIsRejected() {
DispatchPdaController controller = new DispatchPdaController();
assertThrows(ResponseStatusException.class,
() -> controller.list(pda(Set.of("lastleg:pda:operate"), 900L), null));
assertThrows(ResponseStatusException.class,
() -> controller.list(pda(Set.of("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L), null));
assertThrows(ResponseStatusException.class,
() -> controller.list(pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), null), null));
}
@Test
void pdaOpenApiContainsOnlyFrozenDispatchAndOutboundWorkflow() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"),
StandardCharsets.UTF_8);
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"));
assertFalse(yaml.contains("name: customerId"));
assertFalse(yaml.contains("name: warehouseId"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
assertTrue(yaml.contains("inventoryPositionId:"));
assertTrue(yaml.contains("cartonMark:"));
assertTrue(yaml.contains("availablePackages:"));
assertTrue(yaml.contains("pickedPackages:"));
assertEquals(Set.of("inventoryPositionId", "palletNo", "cartonMark", "skuCode", "inboundCode",
"availablePackages", "pickedPackages"),
Arrays.stream(DeliveryResponses.PdaPickPosition.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
assertEquals(Set.of("idempotencyKey"),
Arrays.stream(DeliveryRequests.PdaComplete.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
}
private static AuthenticatedUser pda(Set<String> permissions, Long warehouseId) {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA", permissions,
false, null, null, false, warehouseId);
}
}
@@ -46,7 +46,9 @@ class OverseasProcurementOpenApiContractTest {
assertTrue(yaml.contains("platform:overseas-business:looseboard:create"));
assertTrue(yaml.contains("platform:overseas-business:looseboard:edit"));
assertTrue(yaml.contains("platform:overseas-business:looseboard:status"));
assertFalse(yaml.contains("/api/platform/v1/last-leg/outbound"));
assertFalse(yaml.contains("/api/platform/v1/last-leg/delivery"));
String businessContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:"),
yaml.indexOf("/api/platform/v1/last-leg/relabel-applications:"));
assertFalse(businessContract.contains("/api/platform/v1/last-leg/outbound"));
assertFalse(businessContract.contains("/api/platform/v1/last-leg/delivery"));
}
}