Merge origin/dev and resolve backend conflicts

This commit is contained in:
2026-09-08 20:50:28 +08:00
342 changed files with 13161 additions and 2296 deletions
@@ -0,0 +1,16 @@
package com.baiyue.lastleg;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeanUtils;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class QueryBindingContractTest {
@Test
void mvcQueryRecordsExposeOneResolvableConstructor() {
assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(DeliveryRequests.Query.class));
assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(InboundPlanRequests.Query.class));
}
}
@@ -9,11 +9,14 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LastlegOrderCommandService;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
@@ -32,6 +35,7 @@ import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
// Controller contract tests intentionally instantiate the controller without a Spring context here.
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@@ -59,6 +63,23 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void holdCommandPassesVersionedIdempotentRequestToSingleOrderService() {
var request = new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 2);
controller.hold(user(0L, "PLATFORM"), "100", 11L, "hold-11", request);
verify(commandService).hold("100", 11L, "hold-11", request, "1", "tester");
}
@Test
void manualNodeQueryUsesOrderScopeRatherThanContainerScope() {
controller.manualNodes(user(0L, "PLATFORM"), "100", 11L);
verify(commandService).manualNodes("100", 11L);
verify(service, never()).containerProgressDetail(org.mockito.ArgumentMatchers.anyString());
}
@Test
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null);
@@ -68,6 +89,24 @@ class LastlegOrderControllerTest {
verify(service).containerProgressPage(summaryQuery);
}
@Test
void containerEndpointsUseCanonicalLastMileOrderAuthorities() throws Exception {
var list = LastlegOrderController.class.getMethod("containerSummaries",
AuthenticatedUser.class, LastlegOrderRequests.ContainerProgressQuery.class);
var detail = LastlegOrderController.class.getMethod("containerSummaryDetail",
AuthenticatedUser.class, String.class);
var change = LastlegOrderController.class.getMethod("changeReceivingWarehouse",
AuthenticatedUser.class, String.class, String.class,
LastlegOrderRequests.ChangeReceivingWarehouse.class);
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')",
list.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')",
detail.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:edit')",
change.getAnnotation(PreAuthorize.class).value());
}
@Test
void containerSummariesRejectMerchantIdentity() {
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null);
@@ -130,6 +169,7 @@ class LastlegOrderControllerTest {
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class));
ReflectionTestUtils.setField(controller, "orderService", service);
ReflectionTestUtils.setField(controller, "commandService", commandService);
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
return controller;
}
@@ -0,0 +1,118 @@
package com.baiyue.lastleg.customervisit.service;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
import com.baiyue.lastleg.customervisit.entity.CustomerVisit;
import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerVisitServiceTest {
@Mock private CustomerVisitMapper visits;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private DeliveryOperationMapper deliveryOperations;
@Mock private LastlegOrderMapper lastMileOrders;
private CustomerVisitService service;
@BeforeEach
void setUp() {
service = new CustomerVisitService();
ReflectionTestUtils.setField(service, "visits", visits);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
}
@Test
void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() {
DeliveryOrder delivery = delivery("DELIVERED");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", 0L, "错误倒退")))
.isInstanceOf(ResponseStatusException.class)
.satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409))
.hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY");
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Test
void replaysDeliveredToDeliveredWithoutChangingDeliveryOrAddingHistory() {
DeliveryOrder delivery = delivery("DELIVERED");
CustomerVisit visit = new CustomerVisit();
visit.setId(33L);
visit.setStatus("PENDING");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null));
assertThat(result.replayed()).isTrue();
assertThat(result.deliveryStatus()).isEqualTo("DELIVERED");
assertThat(result.visitId()).isEqualTo(33L);
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Test
void rejectsForwardTransitionWithStaleVersion() {
DeliveryOrder delivery = delivery("WAIT_DELIVERY");
delivery.setVersion(3L);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 2L, null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Test
void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() {
DeliveryOrder delivery = delivery("DELIVERING");
delivery.setVersion(1L);
LastlegOrder tail = new LastlegOrder();
tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED");
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail));
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
assertThat(result.replayed()).isFalse();
assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED");
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
}
private DeliveryOrder delivery(String status) {
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(11L);
delivery.setCustomerId("1001");
delivery.setDeliveryNo("DL-1");
delivery.setStatus(status);
return delivery;
}
}
@@ -66,6 +66,6 @@ class DeliveryLongIdJsonContractTest {
DELIVERY_ID,
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null);
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DropshipOutboundCandidateMapperContractTest {
@Test
void candidateQueryIsSingleStatementAndFailsClosedOnEveryEligibilityFact() throws Exception {
String sql = select("listOutboundCandidates", String.class, String.class);
for (String token : new String[] {"p.customer_id=#{customerId}", "p.status='ACTIVE'",
"o.order_type='ONE_CLICK_DROPSHIP'", "COALESCE(o.detained,0)=0", "pos.status='OCCUPIED'",
"pos.available_packages>0", "s.package_count>0", "c.status='AVAILABLE'",
"ll_dispatch_pallet_lock", "ll_warehouse_transfer_inventory_allocation",
"ll_outbound_pallet_item", "pos.overseas_warehouse_id=#{warehouseId}", "LIMIT 500"}) {
assertTrue(sql.contains(token), token);
}
assertTrue(sql.contains("LEAST(pos.available_packages,s.package_count"));
}
@Test
void creationLockUsesTheSameEligibilityContractAndLocksOneSkuDimension() throws Exception {
String sql = select("lockOutboundCandidate", String.class, Long.class, String.class);
assertTrue(sql.contains("AND pos.id=#{positionId}"));
assertTrue(sql.contains("AND s.sku_code=#{skuCode}"));
assertTrue(sql.contains("LIMIT 1 FOR UPDATE"));
assertTrue(sql.contains("ll_dispatch_pallet_lock"));
assertTrue(sql.contains("ll_outbound_pallet_item"));
}
private String select(String method, Class<?>... parameterTypes) throws Exception {
Select annotation = DropshipOrderProfileMapper.class.getMethod(method, parameterTypes)
.getAnnotation(Select.class);
assertNotNull(annotation);
return String.join("", annotation.value());
}
}
@@ -11,6 +11,12 @@ 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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
@@ -25,8 +31,31 @@ 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;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PlatformDispatchOutboundContractTest {
@Test
void outboundDetailAcceptsTheDocumentUrlWithoutPagingParameters() throws Exception {
DeliveryTransferController controller = new DeliveryTransferController();
ReflectionTestUtils.setField(controller, "service",
mock(com.baiyue.lastleg.delivery.service.DeliveryTransferService.class));
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver())
.build();
AuthenticatedUser platform = new AuthenticatedUser(1000001L, 0L, 0L, 1L, "platform_admin",
"PLATFORM", "WEB", Set.of(OverseasPermissions.OUTBOUND_LIST));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(platform, null, Set.of()));
try {
mockMvc.perform(get("/api/platform/v1/last-leg/outbound-orders/OB-WH-20260907-030529"))
.andExpect(status().isOk());
} finally {
SecurityContextHolder.clearContext();
}
}
@Test
void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception {
Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class,
@@ -35,7 +64,7 @@ class PlatformDispatchOutboundContractTest {
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);
AuthenticatedUser.class, String.class);
Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound",
AuthenticatedUser.class, String.class, DeliveryRequests.Cancel.class);
@@ -43,6 +72,7 @@ class PlatformDispatchOutboundContractTest {
assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL);
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
assertEquals(2, outboundDetail.getParameterCount());
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) {
assertTrue(Arrays.stream(method.getParameters())
@@ -60,15 +90,20 @@ class PlatformDispatchOutboundContractTest {
void oneClickRequiresCustomerWhileOutboundListKeepsOptionalFilter() throws Exception {
Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class,
String.class, DeliveryRequests.OneClickOutbound.class);
Method candidates = DeliveryTransferController.class.getMethod("oneClickCandidates", AuthenticatedUser.class,
String.class, String.class);
Method page = DeliveryTransferController.class.getMethod("outboundPage", AuthenticatedUser.class,
String.class, DeliveryRequests.Query.class);
assertPermission(create, OverseasPermissions.OUTBOUND_CREATE);
assertPermission(candidates, 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());
assertTrue(candidates.getParameters()[1].getAnnotation(RequestParam.class).required());
assertFalse(candidates.getParameters()[2].getAnnotation(RequestParam.class).required());
assertNotNull(pageCustomer);
assertFalse(pageCustomer.required());
@@ -91,8 +126,7 @@ class PlatformDispatchOutboundContractTest {
() -> 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)));
() -> deliveryController.outboundDetail(merchant, "OUT-101"));
assertEquals(403, dispatchRejected.getStatusCode().value());
assertEquals(403, outboundRejected.getStatusCode().value());
@@ -137,6 +171,10 @@ class PlatformDispatchOutboundContractTest {
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
assertFalse(action.contains("name: customerId"));
}
assertTrue(outboundDetail.contains("pallets[].palletNo"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates:"));
assertTrue(yaml.contains("OneClickOutboundCandidate"));
assertTrue(yaml.contains("单次 SQL 返回,不逐行补查"));
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"));
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.delivery.model;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DeliveryStatusPolicyTest {
@Test
void exposesOnlyServerAuthorizedActionsForEachState() {
assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY"))
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING"))
.containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty();
assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty();
}
@Test
void terminalStatesCannotReenterTheDeliveryFlow() {
assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "WAIT_DELIVERY")).isFalse();
assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "DELIVERING")).isFalse();
assertThat(DeliveryStatusPolicy.canTransition("REJECTED", "DELIVERING")).isFalse();
assertThat(DeliveryStatusPolicy.canTransition("CANCELLED", "DELIVERED")).isFalse();
}
}
@@ -19,6 +19,7 @@ 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.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -34,6 +35,8 @@ 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 com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -53,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
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.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -68,6 +72,7 @@ class DeliveryTransferServiceTest {
@Mock DeliveryOperationMapper deliveryOperations;
@Mock InboundPlanPalletAllocationMapper planAllocations;
@Mock InboundPlanLineMapper planLines;
@Mock InboundPlanCartonMapper inboundCartons;
@Mock LastlegOrderMapper tailOrders;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderDetailMapper tailOrderDetails;
@@ -77,6 +82,7 @@ class DeliveryTransferServiceTest {
@Mock InventoryBalanceMapper inventoryBalances;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipCartonMapper dropshipCartons;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
private DeliveryTransferService service;
@@ -92,6 +98,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
ReflectionTestUtils.setField(service, "inboundCartons", inboundCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
@@ -102,6 +109,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
}
@Test
@@ -140,6 +148,40 @@ class DeliveryTransferServiceTest {
assertTrue(!result.replayed());
}
@Test
void transferBackfillsReceivedPlanPalletsBeforeCheckingDispatchItems() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
when(dispatchItems.bindReceivedPallets("TENANT-A", 101L)).thenReturn(1);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-after-receiving", null));
var ordered = inOrder(dispatchItems);
ordered.verify(dispatchItems).bindReceivedPallets("TENANT-A", 101L);
ordered.verify(dispatchItems).findByDispatchOrder("TENANT-A", 101L);
}
@Test
void transferCollectsLxCodesWhenAnInboundCodeBelongsToMultipleTailOrders() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
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)));
LastlegOrder primary = new LastlegOrder();
primary.setCustomerId("TENANT-A"); primary.setInboundCode("IN-A"); primary.setLxCode("LX-1");
LastlegOrder derived = new LastlegOrder();
derived.setCustomerId("TENANT-A"); derived.setInboundCode("IN-A"); derived.setLxCode(null);
when(tailOrders.selectList(any())).thenReturn(List.of(primary, derived), List.of());
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-duplicate-inbound", null));
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
verify(outboundOrders).insert(outbound.capture());
assertEquals("LX-1", outbound.getValue().getLxCodes());
}
@Test
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
@@ -235,20 +277,26 @@ class DeliveryTransferServiceTest {
}
@Test
void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() {
void outboundDetailIncludesPalletCodesForDisplay() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
OutboundPalletItem pallet = pallet(501L, "IP-ONT8-20260907-000001-P001", "ONT8", "Ontario", "IN-A", 12);
pallet.setSkuCode("SKU-1");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
assertEquals("OUT-301", detail.outbound().outboundNo());
assertEquals("DEL-401", detail.delivery().deliveryNo());
assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents())
.noneMatch(component -> "pallets".equals(component.getName())));
verify(outboundPallets, never()).selectPage(any(Page.class), any());
assertEquals(1, detail.pallets().size());
assertEquals("IP-ONT8-20260907-000001-P001", detail.pallets().get(0).palletNo());
assertEquals("IN-A", detail.pallets().get(0).inboundCode());
assertEquals("ONT8", detail.pallets().get(0).destinationWarehouseCode());
assertEquals("SKU-1", detail.pallets().get(0).skuCode());
assertEquals(12, detail.pallets().get(0).packageCount());
verify(outboundPallets).findByOutbound("TENANT-A", 301L);
}
@Test
@@ -258,8 +306,7 @@ class DeliveryTransferServiceTest {
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));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
assertEquals("OUT-301", detail.outbound().outboundNo());
verify(deliveryOrders).lockByOutbound("TENANT-A", 301L);
@@ -358,12 +405,8 @@ class DeliveryTransferServiceTest {
@Test
void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() {
InventoryPosition position = new InventoryPosition();
position.setId(901L); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1");
position.setPalletId(501L); position.setPalletNo("PLT-501"); position.setAvailablePackages(1L);
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
when(inventoryPositions.selectById(901L)).thenReturn(position);
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L);
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(candidate);
when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "MIXED", 1L)).thenReturn(List.of());
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick(
@@ -371,9 +414,43 @@ class DeliveryTransferServiceTest {
new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "one-click-mixed")));
assertEquals(409, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("一件代发可用箱数不足"));
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
}
@Test
void oneClickCandidatesUseOneAuthoritativeQueryAndExposeStableBusinessFields() {
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "DS-A002", "SKU-1", 3L);
candidate.setOverseasWarehouseName("Los Angeles"); candidate.setInboundCode("IN-1");
candidate.setProductName("Product 1");
when(dropshipProfiles.listOutboundCandidates("TENANT-A", "WH-1")).thenReturn(List.of(candidate));
List<DeliveryResponses.OneClickCandidate> result = service.oneClickCandidates("TENANT-A", " WH-1 ");
assertEquals(1, result.size());
assertEquals(901L, result.get(0).inventoryPositionId());
assertEquals("DS-A002", result.get(0).palletNo());
assertEquals("SKU-1", result.get(0).skuCode());
assertEquals(3L, result.get(0).availablePackages());
verify(dropshipProfiles).listOutboundCandidates("TENANT-A", "WH-1");
verify(inventoryPositions, never()).selectById(any());
}
@Test
void oneClickCreationRejectsOrdinaryInventoryBeforeAnyWrite() {
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick(
"TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "ordinary-stock")));
assertEquals(409, exception.getStatusCode().value());
assertEquals("所选库存不是可用的一件代发库存,或已被其他作业占用", exception.getReason());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(outboundPallets, never()).insert(any(OutboundPalletItem.class));
verify(pdaTasks, never()).insert(any(OutboundPdaTask.class));
}
@Test
void outboundPdaListAndDetailUseOnlyBoundWarehouse() throws Exception {
assertNotNull(DeliveryTransferService.class.getMethod("pdaPick", String.class, String.class,
@@ -393,6 +470,53 @@ class DeliveryTransferServiceTest {
verify(pdaTasks).listByWarehouse("WH-1", null);
}
@Test
void outboundNumberResolvesDispatchWorkflowInBoundWarehouse() {
OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key");
outbound.setOverseasWarehouseId("WH-1");
outbound.setOutboundType("DISPATCH_DELIVERY");
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch);
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
assertEquals("DISPATCH", result.workflow());
assertEquals("DSP-401", result.dispatchNo());
assertEquals("OUT-301", result.outboundNo());
}
@Test
void outboundNumberResolvesOneClickTaskInBoundWarehouse() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-1");
outbound.setOutboundType("ONE_CLICK_DROPSHIP");
OutboundPdaTask task = new OutboundPdaTask();
task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setTaskNo("PDA-701");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(pdaTasks.findByOutboundAndWarehouse("TENANT-A", 301L, "WH-1")).thenReturn(task);
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
assertEquals("OUTBOUND", result.workflow());
assertEquals("PDA-701", result.taskNo());
assertEquals("OUT-301", result.outboundNo());
}
@Test
void pdaTaskResolutionDoesNotLeakAnotherWarehouse() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
outbound.setOverseasWarehouseId("WH-2");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.resolvePdaTask("WH-1", "OUT-301"));
assertEquals(404, exception.getStatusCode().value());
verify(pdaTasks, never()).findByOutboundAndWarehouse(any(), any(), any());
}
@Test
void outboundPdaDetailReturnsEveryPickablePositionWithStableBusinessCodes() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
@@ -565,4 +689,12 @@ class DeliveryTransferServiceTest {
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
return value;
}
private static DropshipOutboundCandidate dropshipCandidate(Long positionId, Long profileId, String warehouseId,
String palletNo, String sku, Long packages) {
DropshipOutboundCandidate value = new DropshipOutboundCandidate();
value.setInventoryPositionId(positionId); value.setProfileId(profileId); value.setWarehousePalletId(501L);
value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku);
value.setAvailablePackages(packages); return value;
}
}
@@ -35,6 +35,20 @@ class DispatchCandidateMapperContractTest {
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
@Test
void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
assertTrue(sql.contains("pos.overseas_warehouse_id"));
assertTrue(sql.contains("CAST(ip.overseas_warehouse_id AS CHAR)"));
assertTrue(sql.contains("ll_inventory_balance"));
}
}
private void assertConstructorColumnOrder(Method method) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
int previous = -1;
@@ -4,6 +4,8 @@ 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.DispatchDraft;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
@@ -16,6 +18,8 @@ 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 com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +36,7 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -49,6 +54,7 @@ class DispatchServiceTest {
@Mock DispatchDraftMapper drafts;
@Mock DispatchDraftContainerMapper draftContainers;
@Mock DispatchPdaTaskMapper pdaTasks;
@Mock LastlegOrderMapper tailOrders;
private DispatchService service;
@BeforeEach
@@ -61,8 +67,26 @@ class DispatchServiceTest {
ReflectionTestUtils.setField(service, "drafts", drafts);
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
lenient().when(tailOrders.selectList(any())).thenReturn(List.of());
}
@Test
void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
LastlegOrder held = new LastlegOrder();
held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true);
when(tailOrders.selectList(any())).thenReturn(List.of(held));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
null, null, null)));
assertEquals(409, exception.getStatusCode().value());
assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
@@ -127,6 +151,21 @@ class DispatchServiceTest {
verify(items).insert(any(DispatchItem.class));
}
@Test
void preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() {
DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8",
12, new BigDecimal("1.2000"), "RECEIVED");
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
@@ -145,15 +184,41 @@ class DispatchServiceTest {
void preArrangeConvertsOwnedDraftLockInsteadOfInsertingAnotherLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
DispatchPalletLock lock = new DispatchPalletLock();
lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
DispatchDraft draft = new DispatchDraft();
draft.setId(31L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
when(drafts.lockActive("draft-1", "7")).thenReturn(draft);
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1"));
assertNotNull(lock.getDispatchOrderId());
assertEquals(null, lock.getDraftToken());
assertNull(lock.getDraftToken());
assertNull(lock.getDraftContainerId());
assertEquals("RELEASED", draft.getStatus());
assertNotNull(draft.getReleasedAt());
verify(locks).updateById(lock);
verify(locks, never()).insert(any(DispatchPalletLock.class));
verify(draftContainers).delete(any(LambdaQueryWrapper.class));
verify(drafts).updateById(draft);
}
@Test
void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() {
DispatchDraft draft = new DispatchDraft();
draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
DispatchDraftContainer container = new DispatchDraftContainer();
container.setId(21L); container.setDraftToken("draft-1"); container.setCapacity(12);
DispatchPalletLock owned = new DispatchPalletLock();
owned.setPlanPalletId(10L); owned.setDraftToken("draft-1"); owned.setDraftContainerId(21L);
when(drafts.findActiveByOperator("7")).thenReturn(List.of(draft));
when(draftContainers.findByToken("draft-1")).thenReturn(List.of(container));
when(locks.findActiveByDraftContainer("draft-1", 21L)).thenReturn(List.of(owned));
var result = service.activeDrafts("7");
assertEquals(List.of("10"), result.get(0).containers().get(0).palletIds());
verify(locks).findActiveByDraftContainer("draft-1", 21L);
}
@Test
@@ -141,7 +141,8 @@ class DropshipOrderServiceTest {
private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) {
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo);
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId);
receipt.setPalletNo("WH-" + palletId); receipt.setSourcePalletNo(palletNo);
receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages);
return receipt;
}
@@ -0,0 +1,159 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
@ExtendWith(MockitoExtension.class)
class ExceptionOrderLifecycleTest {
@Mock private ExceptionOrderMapper exceptionOrders;
@Mock private ExceptionOrderCommandMapper commands;
@Mock private ExceptionReturnItemMapper returnItems;
@Mock private InventoryCommandService inventoryCommands;
@Mock private ExceptionEvidenceFileGateway evidenceFiles;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private DeliveryOperationMapper deliveryOperations;
@Mock private LastlegOrderMapper lastMileOrders;
private ExceptionOrderService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
service = new ExceptionOrderService();
objectMapper = new ObjectMapper().findAndRegisterModules();
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "evidenceFiles", evidenceFiles);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@Test
void startsPendingExceptionWithVersionGateAndPersistsIdempotentResult() {
ExceptionOrder order = order("PENDING", 0);
when(commands.findByKey("1001", "START_PROCESSING", "start-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
var result = service.startProcessing("1001", "9001", "EX-1",
new ExceptionOrderRequests.StartProcessing(0, "start-1", "联系客户并核对退件"));
assertThat(result.status()).isEqualTo("PROCESSING");
assertThat(result.version()).isEqualTo(1);
assertThat(result.replayed()).isFalse();
verify(exceptionOrders).updateById(order);
ArgumentCaptor<ExceptionOrderCommand> command = ArgumentCaptor.forClass(ExceptionOrderCommand.class);
verify(commands).insert(command.capture());
assertThat(command.getValue().getResultJson()).contains("PROCESSING");
}
@Test
void rejectsStaleVersionWithoutMutation() {
when(commands.findByKey("1001", "START_PROCESSING", "start-2")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order("PENDING", 2));
assertThatThrownBy(() -> service.startProcessing("1001", "9001", "EX-1",
new ExceptionOrderRequests.StartProcessing(1, "start-2", null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verify(exceptionOrders, never()).updateById(any(ExceptionOrder.class));
verify(commands, never()).insert(any(ExceptionOrderCommand.class));
}
@Test
void replaysSameCommandWithoutUpdatingAgain() throws Exception {
ExceptionOrderResponses.CommandResult saved = new ExceptionOrderResponses.CommandResult(
1L, "EX-1", "PROCESSING", 1, null, 0, null, false);
ExceptionOrderRequests.StartProcessing request =
new ExceptionOrderRequests.StartProcessing(0, "start-3", null);
ExceptionOrderCommand command = new ExceptionOrderCommand();
command.setRequestHash(hashFor(request));
command.setResultJson(objectMapper.writeValueAsString(saved));
when(commands.findByKey("1001", "START_PROCESSING", "start-3")).thenReturn(command);
var replayed = service.startProcessing("1001", "9001", "EX-1", request);
assertThat(replayed.replayed()).isTrue();
assertThat(replayed.status()).isEqualTo("PROCESSING");
verify(exceptionOrders, never()).lockByNo(any(), any());
}
@Test
void completesWarehouseReturnOnlyAfterInventoryRestoreSucceeds() {
ExceptionOrder order = order("PROCESSING", 1);
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE");
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L);
order.setDeliveryNo("DL-1");
ExceptionReturnItem source = new ExceptionReturnItem();
source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L);
source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L);
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30);
when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source));
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt));
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(lastMileOrders.selectList(any())).thenReturn(List.of());
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
"PLT-201", 301L, "LOC-301")));
var result = service.complete("1001", "9001", "EX-1", request);
assertThat(result.status()).isEqualTo("COMPLETED");
assertThat(result.inventoryAdjustmentId()).isEqualTo(81L);
assertThat(result.restoredPackages()).isEqualTo(2);
assertThat(delivery.getStatus()).isEqualTo("RETURNED");
verify(evidenceFiles).validate("1001", 1L, 101L);
verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class));
}
private String hashFor(Object request) {
return (String) ReflectionTestUtils.invokeMethod(service, "hash", request);
}
private ExceptionOrder order(String status, long version) {
ExceptionOrder order = new ExceptionOrder();
order.setId(1L); order.setCustomerId("1001"); order.setExceptionNo("EX-1");
order.setStatus(status); order.setVersion(version);
return order;
}
}
@@ -27,6 +27,13 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("expected_loading_at"));
assertTrue(schema.contains("planned_arrival_date"));
assertTrue(schema.contains("creation_mode"));
assertTrue(schema.contains("pallet_type"));
assertTrue(schema.contains("create table if not exists ll_order_command_record"));
assertTrue(schema.contains("create table if not exists ll_order_hold_history"));
assertTrue(schema.contains("create table if not exists ll_order_manual_node"));
assertTrue(schema.contains("create table if not exists ll_order_manual_node_history"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item"));
assertTrue(schema.contains("fba_warehouse_code"));
assertFalse(schema.contains("cabinet_no"));
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.inboundplan;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TailPlanCommandOpenApiContractTest {
@Test
void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception {
try (var stream = getClass().getClassLoader()
.getResourceAsStream("openapi/overseas-business-v1.yaml")) {
assertTrue(stream != null);
String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:"));
assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:"));
assertFalse(api.contains("plannedWarehouseId"));
assertFalse(api.contains("plannedWarehouseCode"));
assertFalse(api.contains("plannedWarehouseName"));
int manualSchemaStart = api.indexOf(" ManualInboundPlanRequest:");
int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart);
assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart);
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode"));
assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents())
.anyMatch(component -> "inboundCode".equals(component.getName())));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:"));
assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:"));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:"));
assertTrue(api.contains("minItems: 8"));
assertTrue(api.contains("maxItems: 8"));
assertTrue(api.contains("ACTUAL_ARRIVAL_WAITING_UNLOAD"));
}
}
}
@@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
@@ -19,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import java.time.LocalDateTime;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
@@ -61,31 +62,19 @@ class InboundPlanControllerTest {
}
@Test
void platformConversionRequiresAnExplicitCustomer() {
assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("平台生成入库计划必须指定客户");
verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
void platformManualPlanPassesVersionedIdempotentCommand() {
ManualInboundPlanRequest request = manualRequest();
controller.manual(platform(), "manual-key", request);
verify(service).convertManual(request, "manual-key", "7");
}
@Test
void platformConversionPassesTheProvidedLongCustomerId() {
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L,
"193000000000200003");
controller.convert(platform(), request);
verify(service).convert("193000000000200003", 9223372036854775807L);
}
@Test
void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() {
void merchantCannotCreateManualPlan() {
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200");
controller.convert(customer, request);
verify(service).convert("100", 2L);
assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest()))
.isInstanceOf(ResponseStatusException.class);
verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -109,4 +98,9 @@ class InboundPlanControllerTest {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of("platform:overseas-warehouse:inbound-order:list"));
}
private static ManualInboundPlanRequest manualRequest() {
return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0,
LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓");
}
}
@@ -33,7 +33,7 @@ class InboundPlanPermissionContractTest {
@Test
void conversionUsesCanonicalInboundOrderCreateAuthority() {
Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("convert"))
.filter(candidate -> candidate.getName().equals("manual"))
.findFirst()
.orElseThrow();
@@ -17,5 +17,34 @@ class InboundPlanOrderMapperContractTest {
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,'')))"));
assertTrue(sql.contains("THEN 1 ELSE COUNT(DISTINCT"));
assertTrue(sql.contains("0 AS destinationMismatchCount"));
assertTrue(sql.contains("o.overseas_warehouse_id AS overseasWarehouseId"));
assertTrue(sql.contains("o.overseas_warehouse_code AS overseasWarehouseCode"));
assertTrue(sql.contains("o.overseas_warehouse_name AS overseasWarehouseName"));
}
@Test
void receivingWarehouseSnapshotIsReadFromTenantScopedOrderFact() throws Exception {
Select select = InboundPlanOrderMapper.class
.getMethod("findReceivingWarehouse", String.class, Long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("overseas_warehouse_code AS overseasWarehouseCode"));
assertTrue(sql.contains("overseas_warehouse_name AS overseasWarehouseName"));
assertTrue(sql.contains("customer_id=#{customerId} AND id=#{orderId}"));
assertTrue(!sql.contains("destination_warehouse"));
}
@Test
void manualPlanLocksEveryOrderInTheSelectedStableContainer() throws Exception {
Select select = InboundPlanOrderMapper.class
.getMethod("lockContainerWarehouseFacts", String.class, Long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("c.source_container_booking_id=target.source_container_booking_id"));
assertTrue(sql.contains("target.customer_id=#{customerId} AND target.id=#{orderId}"));
assertTrue(sql.contains("c.overseas_warehouse_id AS overseasWarehouseId"));
assertTrue(sql.contains("ORDER BY c.customer_id,c.id FOR UPDATE"));
}
}
@@ -11,6 +11,7 @@ import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpWarehouseResourceGatewayTest {
@@ -32,7 +33,9 @@ class HttpWarehouseResourceGatewayTest {
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-1",
"warehouseId":2092040869757231105,"locationId":2092040891076878337,
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3}}
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3,
"locationType":"STORAGE","locationStatus":"IDLE","reservable":true,
"reservationBlockReason":null}}
""", MediaType.APPLICATION_JSON));
WarehouseResourceGateway.WarehousePalletReference result = gateway.resolvePallet("PAL-1");
@@ -43,6 +46,40 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
@Test
void resolveRejectsPalletWhoseLocationIsNotReservable() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-BLOCKED"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-BLOCKED",
"warehouseId":2092040869757231105,"locationId":2092040891076878337,
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3,
"locationType":"STORAGE","locationStatus":"OCCUPIED","reservable":false,
"reservationBlockReason":"LOCATION_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet("PAL-BLOCKED"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409")
.hasMessageContaining("Pallet location is not idle");
server.verify();
}
@Test
void resolveFailsClosedWhenWarehouseOmitsReservationFacts() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-UNKNOWN",
"warehouseId":2092040869757231105,"locationId":2092040891076878337,
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet("PAL-UNKNOWN"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409")
.hasMessageContaining("availability could not be verified");
server.verify();
}
@Test
void ensureDropshipStillMapsWarehouseEntityId() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/ensure-dropship"))
@@ -72,4 +109,36 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
@Test
void reserveMapsWarehouseLocationConflictToStableConflict() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withStatus(org.springframework.http.HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_JSON)
.body("""
{"code":"A0409","message":"Location is not idle","data":null}
"""));
assertThatThrownBy(() -> gateway.reserve("1", 2L, 3L, 4L,
"LASTLEG_INBOUND", "plan:5", "reservation-1"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409")
.hasMessageContaining("Warehouse resource reservation was rejected");
server.verify();
}
@Test
void reserveMapsBusinessConflictEnvelopeToStableConflict() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("""
{"code":"A0409","message":"Location is not idle","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.reserve("1", 2L, 3L, 4L,
"LASTLEG_INBOUND", "plan:5", "reservation-1"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409")
.hasMessageContaining("Location is not idle");
server.verify();
}
}
@@ -167,6 +167,41 @@ class InboundPdaWorkflowServiceTest {
targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse")));
}
@Test
void resolvedAssignmentReplacesPlanPalletNumberWithPhysicalWarehouseCode() {
targetPallet.setPalletNo("IP-ACY2-20260907-597057-P001");
targetPallet.setWarehousePalletId(null);
targetPallet.setWarehouseLocationId(null);
targetPallet.setWarehouseResourceLockId(null);
targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT");
WarehouseResourceGateway.WarehousePalletReference resource =
new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L,
"IDLE", "ACY2", 1);
when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_CONTAINER_DESTINATION_PALLET",
"CONT-1:WH-A", "INBOUND_CONTAINER_DESTINATION_PALLET:CONT-1:WH-A:188")).thenReturn(999L);
service.assignResolvedPallet("100", plan, targetPallet, resource);
assertEquals("A002", targetPallet.getPalletNo());
assertEquals(188L, targetPallet.getWarehousePalletId());
assertEquals(288L, targetPallet.getWarehouseLocationId());
verify(repository).update(targetPallet);
}
@Test
void resolvedAssignmentRejectsPhysicalPalletWithoutBusinessCodeBeforeReservation() {
targetPallet.setWarehouseResourceLockId(null);
WarehouseResourceGateway.WarehousePalletReference resource =
new WarehouseResourceGateway.WarehousePalletReference(188L, " ", 88L, 288L,
"IDLE", "ACY2", 1);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.assignResolvedPallet("100", plan, targetPallet, resource));
assertEquals(409, exception.getStatusCode().value());
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any());
}
@Test
void cartonScanBindsOnlyExpectedCartonAndRemoveMakesItScannableAgain() {
InboundPlanCarton carton = carton("BOX-001", "UNASSIGNED", targetLine.getId(), "WH-A");
@@ -522,6 +557,8 @@ class InboundPdaWorkflowServiceTest {
@Test
void dropshipTaskIsMarkedAndWholePalletPickReceivesThePlannedPalletAtOnce() {
plan.setSourceOrderId(101L);
targetPallet.setPalletNo("WH-PALLET-21");
targetPallet.setSourcePalletNo("PLT-21");
LastlegOrder order = new LastlegOrder();
order.setId(101L); order.setOrderType("ONE_CLICK_DROPSHIP");
when(repository.order(101L)).thenReturn(order);
@@ -14,6 +14,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -50,6 +51,7 @@ class InboundStockReceiptServiceTest {
@Mock InboundStockReceiptMapper receipts;
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
@Mock InventoryCommandService inventory;
@Mock WarehouseResourceGateway warehouseGateway;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
private InboundStockReceiptService service;
@@ -62,6 +64,12 @@ class InboundStockReceiptServiceTest {
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
long locationId = invocation.getArgument(1);
return new WarehouseResourceGateway.WarehouseLocationReference(locationId,
"PDA-LAX-" + locationId, "STORAGE", BigDecimal.TEN, "OCCUPIED");
});
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
@@ -86,11 +94,15 @@ class InboundStockReceiptServiceTest {
assertEquals("SKU-1", request.getValue().skuCode());
assertEquals("Customer", request.getValue().customerName());
assertEquals("900", request.getValue().overseasWarehouseId());
assertEquals("US-LAX-01", request.getValue().overseasWarehouseCode());
assertEquals("Los Angeles", request.getValue().overseasWarehouseName());
assertEquals("PDA-LAX-3001", request.getValue().warehouseLocationCode());
assertEquals("PLT-21", request.getValue().palletNo());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
assertEquals(10L, receipt.getValue().getInventoryPositionId());
assertEquals("PLT-21", receipt.getValue().getPalletNo());
assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
@@ -216,6 +228,7 @@ class InboundStockReceiptServiceTest {
void activatesDropshipAfterGenericStockInReceiptIsCreated() {
Fixture fixture = fixture(5, 5, 5, 10);
fixture.order.setOrderType("ONE_CLICK_DROPSHIP");
fixture.pallet.setPalletNo("WH-PALLET-21");
when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(dropshipPallet("PLT-21", 5)));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
@@ -228,6 +241,8 @@ class InboundStockReceiptServiceTest {
verify(dropshipOrders).activateAfterStockIn(eq("100"), eq(101L), eq(2L), activationReceipts.capture());
assertEquals(1, activationReceipts.getValue().size());
assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId());
assertEquals("PLT-21", activationReceipts.getValue().get(0).getSourcePalletNo());
assertEquals("WH-PALLET-21", activationReceipts.getValue().get(0).getPalletNo());
}
@Test
@@ -292,7 +307,8 @@ class InboundStockReceiptServiceTest {
line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0"));
LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100");
order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setSourceWarehouseId("100");
order.setOverseasWarehouseId("900"); order.setOverseasWarehouseName("Los Angeles");
order.setOverseasWarehouseId("900"); order.setOverseasWarehouseCode("US-LAX-01");
order.setOverseasWarehouseName("Los Angeles");
order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
@@ -309,7 +325,8 @@ class InboundStockReceiptServiceTest {
private InboundPlanPallet pallet(long id, long warehousePalletId) {
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(id); pallet.setPlanId(1L);
pallet.setPalletNo("PLT-" + id); pallet.setWarehousePalletId(warehousePalletId);
pallet.setPalletNo("PLT-" + id); pallet.setSourcePalletNo("PLT-" + id);
pallet.setWarehousePalletId(warehousePalletId);
pallet.setWarehouseLocationId(3001L); return pallet;
}
@@ -112,9 +112,6 @@ class ReceivingContainerServiceBehaviorTest {
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
when(plans.claimReceivingWarehouse("100", 2L, 100L)).thenReturn(1);
InboundPlan claimed = plan("CONT-A"); claimed.setOverseasWarehouseId(100L);
when(plans.selectById(2L)).thenReturn(claimed);
var response = service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("same-key"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L);
@@ -179,6 +176,27 @@ class ReceivingContainerServiceBehaviorTest {
.containsExactly("CONT-EARLY", "CONT-LATE", "CONT-NULL", "CONT-BLOCKED");
}
@Test
void invalidWarehouseSnapshotOnlyBlocksItsContainerWithoutFailingHealthyCandidates() {
InboundPlan brokenPlan = plan("CONT-BROKEN");
InboundPlan healthyPlan = plan("CONT-HEALTHY"); healthyPlan.setId(3L);
InboundPlanLine brokenLine = line("FBA");
InboundPlanLine healthyLine = line("FBA"); healthyLine.setId(11L); healthyLine.setOrderId(2L); healthyLine.setPlanId(3L);
LastlegOrder broken = containerOrder(1L, "100", "CONT-BROKEN", "BOOKING-A", "IN-1");
broken.setOverseasWarehouseCode(null);
LastlegOrder healthy = containerOrder(2L, "100", "CONT-HEALTHY", "BOOKING-B", "IN-2");
when(plans.selectList(any())).thenReturn(List.of(brokenPlan, healthyPlan));
when(lines.selectList(any())).thenReturn(List.of(brokenLine, healthyLine));
when(orders.selectList(any())).thenReturn(List.of(broken, healthy));
var result = service.candidates("7", 100L);
assertThat(result).extracting("containerNo").containsExactly("CONT-HEALTHY", "CONT-BROKEN");
assertThat(result.get(0).operable()).isTrue();
assertThat(result.get(1).operable()).isFalse();
assertThat(result.get(1).blockReason()).isEqualTo("柜内接柜仓业务快照不一致");
}
@Test
void candidatesIncludeUnassignedAndSameWarehouseButHideOtherWarehouse() {
InboundPlan unassigned = plan("CONT-A");
@@ -198,6 +216,47 @@ class ReceivingContainerServiceBehaviorTest {
.containsExactly("CONT-A", "CONT-B");
}
@Test
void visibleCandidateWithUnassignedPlanCanOpenDetailInTheSameWarehouse() {
InboundPlan unassigned = plan("TSTU0000204");
unassigned.setOverseasWarehouseId(null);
InboundPlanLine planLine = line("FBA");
LastlegOrder order = containerOrder(1L, "100", "TSTU0000204", "BOOKING-204", "IN-204");
when(plans.selectList(any())).thenReturn(List.of(unassigned));
when(lines.selectList(any())).thenReturn(List.of(planLine));
when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
var candidate = service.candidates("7", 100L).get(0);
var detail = service.detail("7", 100L, "TSTU0000204");
assertThat(candidate.operable()).isTrue();
assertThat(detail.overseasWarehouseId()).isEqualTo(100L);
assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX");
}
@Test
void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() {
InboundPlan unassigned = plan("TSTU0195472");
unassigned.setOverseasWarehouseId(null);
InboundPlanLine planLine = line("FBA");
LastlegOrder order = containerOrder(1L, "100", "TSTU0195472", "BOOKING-472", "IN-472");
when(plans.selectList(any())).thenReturn(List.of(unassigned));
when(plans.updateById(unassigned)).thenReturn(1);
when(lines.selectList(any())).thenReturn(List.of(planLine));
when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
var result = service.prepare("7", "receiver", 100L, "TSTU0195472",
new ReceivingContainerRequests.Action("bind-warehouse"));
assertThat(result.overseasWarehouseId()).isEqualTo(100L);
assertThat(unassigned.getOverseasWarehouseId()).isEqualTo(100L);
verify(plans).updateById(unassigned);
}
@Test
void candidatesHideContainersReservedByAnotherOperator() {
InboundPlan available = plan("CONT-A");
@@ -652,7 +711,7 @@ class ReceivingContainerServiceBehaviorTest {
private static InboundPlan plan(String containerNo) {
InboundPlan value = new InboundPlan();
value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo);
value.setStatus("SIGNED");
value.setStatus("SIGNED"); value.setOverseasWarehouseId(100L);
return value;
}
@@ -668,7 +727,9 @@ class ReceivingContainerServiceBehaviorTest {
private static LastlegOrder order(String destinationType) {
LastlegOrder value = new LastlegOrder();
value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType);
value.setOverseasWarehouseId("100"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
value.setOverseasWarehouseId("100"); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX");
value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
@@ -690,7 +751,8 @@ class ReceivingContainerServiceBehaviorTest {
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseName("Shared LAX");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0);
return value;
}
@@ -48,9 +48,9 @@ class InboundPlanPaginationTest {
null, "PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED",
LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59));
InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100",
3L, "IN-1", "FIRST_LEG", "W1", "ONT", "安大略仓", "ONT8", "客户A", 10,
BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", null, null, null, null, "UNSCHEDULED",
LocalDateTime.of(2026, 8, 20, 10, 0));
3L, "IN-1", null, "FIRST_LEG", "US-LAX-01", "洛杉矶仓", "W1", "ONT", "安大略仓",
"ONT8", "客户A", 10, 0, BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", null, null, null,
null, "UNSCHEDULED", LocalDateTime.of(2026, 8, 20, 10, 0), 0);
when(queryMapper.count("100", query)).thenReturn(11L);
when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row));
@@ -59,6 +59,8 @@ class InboundPlanPaginationTest {
assertThat(result.records()).containsExactly(row);
assertThat(result.records().get(0).lineId()).isEqualTo(9007199254740993L);
assertThat(result.records().get(0).appointmentStatus()).isNull();
assertThat(result.records().get(0).overseasWarehouseCode()).isEqualTo("US-LAX-01");
assertThat(result.records().get(0).overseasWarehouseName()).isEqualTo("洛杉矶仓");
assertThat(result.pages()).isEqualTo(2);
verify(queryMapper).count("100", query);
verify(queryMapper).rows("100", query, 10, 10);
@@ -71,6 +73,10 @@ class InboundPlanPaginationTest {
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER;
assertThat(sql).contains("a2.customer_id=#{customerId}", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1");
assertThat(sql).contains("di.customer_id=#{customerId} AND </if>di.order_id=l.order_id");
assertThat(sql).contains("o.overseas_warehouse_code overseasWarehouseCode",
"o.overseas_warehouse_name overseasWarehouseName",
"o.overseas_warehouse_id=#{q.overseasWarehouseId}");
assertThat(sql).doesNotContain("l.destination_warehouse_id=#{q.overseasWarehouseId}");
assertThat(sql).contains("q.planNo", "q.inboundCode", "q.status", "q.overseasWarehouseId",
"q.destinationWarehouseCode", "q.warehouse",
"q.fbaWarehouseCode", "q.appointmentStatus", "q.dispatchStatus", "q.createdFrom", "q.createdTo");
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
@@ -15,6 +16,9 @@ import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -28,11 +32,13 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.time.LocalDateTime;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -47,6 +53,8 @@ class InboundPlanServiceTest {
@Mock InboundPlanOrderMapper orderMapper;
@Mock InventoryBalanceMapper inventoryBalanceMapper;
@Mock DropshipOrderService dropshipOrders;
@Mock WarehouseResourceGateway warehouseGateway;
@Mock LastlegOrderCommandRecordMapper commandMapper;
private InboundPlanService service;
@@ -60,10 +68,22 @@ class InboundPlanServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
ReflectionTestUtils.setField(service, "commandMapper", commandMapper);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot(
"193000000000200003", "US-LAX-01", "洛杉矶仓"));
org.mockito.Mockito.lenient().when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(
193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerWarehouseRef(
"100", 2L, "BOOKING-1", "193000000000200003")));
}
@Test
@@ -99,18 +119,107 @@ class InboundPlanServiceTest {
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper).bindInboundPlan(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq("193000000000200003"),
org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq("RK-2"),
org.mockito.ArgumentMatchers.eq(result.planNo()), any());
verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
verify(orderMapper).lockOrder("100", 2L);
assertEquals(2L, result.sourceOrderId());
assertEquals("MSCU6639870", result.containerNo());
assertEquals("US-LAX-01", result.overseasWarehouseCode());
assertEquals("洛杉矶仓", result.overseasWarehouseName());
}
@Test
void manuallyCreatesOneIndependentPlanWithFrozenMetadata() {
SourceOrderSnapshot source = order(2L, "RK-2", "1.0000", "WH-A");
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(
193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()))
.thenReturn(1);
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 2, "STANDARD", true, true, true, "优先收货");
var result = service.convertManual(request, "manual-2", "900");
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture());
assertEquals(2L, plan.getValue().getSourceOrderId());
assertEquals(LocalDate.of(2026, 9, 15), plan.getValue().getPlannedArrivalDate());
assertEquals("STANDARD", plan.getValue().getPalletType());
assertEquals("MANUAL", plan.getValue().getCreationMode());
assertEquals(2, result.palletCount());
verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class));
verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any());
verify(commandMapper).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
verify(orderMapper, never()).lockContainerOrders(any());
verify(orderMapper).lockContainerWarehouseFacts("100", 2L);
}
@Test
void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0,
"193000000000200003", null, null, null,
"WH-A", "WH-A", "仓A", 1, 0, false, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(
193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(3), eq(false), eq(0), any()))
.thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 3, "STANDARD", true, false, false, null),
"manual-id-only", "900");
assertEquals("US-LAX-01", result.overseasWarehouseCode());
assertEquals("洛杉矶仓", result.overseasWarehouseName());
verify(warehouseGateway).requireActiveSharedWarehouse(193000000000200003L);
verify(planMapper).insert(any(InboundPlan.class));
}
@Test
void manualPlanRejectsMixedContainerWarehousesBeforeLockingOrderOrWriting() {
when(orderMapper.lockContainerWarehouseFacts("100", 2L)).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerWarehouseRef("100", 2L, "BOOKING-1", "193000000000200003"),
new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004")));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
1, "STANDARD", false, false, false, null), "mixed-warehouse", "900"));
assertEquals(409, error.getStatusCode().value());
verify(orderMapper, never()).lockOrder(any(), any());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void refusesManualPlanForHeldOrderBeforeWriting() {
SourceOrderSnapshot held = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, new BigDecimal("1.0000"), 10, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, "WH-A", "仓A", 1, 0, true, 5);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 5, LocalDate.of(2026, 9, 15),
1, "STANDARD", false, false, false, null),
"manual-held", "900"));
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void convertsAConsistentDestinationAssignedOrder() {
SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10,
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
"2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
@@ -145,7 +254,35 @@ class InboundPlanServiceTest {
assertEquals(1L, result.sourceOrderId());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
void detailReturnsReceivingWarehouseFromTenantScopedOrderFact() {
InboundPlan plan = existingPlan("100", 1L, "LIP-1");
when(planMapper.selectOne(any())).thenReturn(plan);
when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot("88", "US-NY-01", "纽约仓"));
when(warehouseGateway.requireActiveSharedWarehouse(88L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(88L, "US-NY-01", "纽约仓", "ACTIVE"));
var result = service.detailByPlanNo("100", "LIP-1");
assertEquals("US-NY-01", result.overseasWarehouseCode());
assertEquals("纽约仓", result.overseasWarehouseName());
verify(orderMapper).findReceivingWarehouse("100", 1L);
}
@Test
void detailFailsClosedWhenReceivingWarehouseFactIsMissing() {
InboundPlan plan = existingPlan("100", 1L, "LIP-1");
when(planMapper.selectOne(any())).thenReturn(plan);
when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn(null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.detailByPlanNo("100", "LIP-1"));
assertEquals(409, error.getStatusCode().value());
}
@Test
@@ -160,7 +297,9 @@ class InboundPlanServiceTest {
@Test
void rejectsMissingDestinationWarehouse() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "MSCU6639870", "RK-1",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0);
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, null, null, 1, 0);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
@@ -190,7 +329,9 @@ class InboundPlanServiceTest {
@Test
void transferWarehouseOverridesOriginalCargoDestination() {
SourceOrderSnapshot transferred = new SourceOrderSnapshot(1L, "100", "MSCU6639870", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
"WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(transferred));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
@@ -217,6 +358,8 @@ class InboundPlanServiceTest {
verify(palletMapper, times(2)).insert(pallets.capture());
assertEquals(List.of("SOURCE-P-1", "SOURCE-P-2"),
pallets.getAllValues().stream().map(InboundPlanPallet::getPalletNo).toList());
assertEquals(List.of("SOURCE-P-1", "SOURCE-P-2"),
pallets.getAllValues().stream().map(InboundPlanPallet::getSourcePalletNo).toList());
ArgumentCaptor<InboundPlanPalletAllocation> allocations =
ArgumentCaptor.forClass(InboundPlanPalletAllocation.class);
verify(allocationMapper, times(2)).insert(allocations.capture());
@@ -318,6 +461,8 @@ class InboundPlanServiceTest {
verify(palletMapper, times(2)).insert(pallets.capture());
assertEquals(List.of("SOURCE-P-1", "SOURCE-P-2"),
pallets.getAllValues().stream().map(InboundPlanPallet::getPalletNo).toList());
assertEquals(List.of("SOURCE-P-1", "SOURCE-P-2"),
pallets.getAllValues().stream().map(InboundPlanPallet::getSourcePalletNo).toList());
ArgumentCaptor<InboundPlanPalletAllocation> allocations =
ArgumentCaptor.forClass(InboundPlanPalletAllocation.class);
verify(allocationMapper, times(2)).insert(allocations.capture());
@@ -332,13 +477,35 @@ class InboundPlanServiceTest {
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0)));
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, null, null, 1, 0)));
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
void containerConversionRejectsMixedWarehouseSnapshotsBeforeCreatingAnyPlan() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A")));
SourceOrderSnapshot differentWarehouse = new SourceOrderSnapshot(2L, "200", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0,
"193000000000200004", null, null, null, "WH-B", "WH-B", "仓B", 1, 0, false, 0);
when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(differentWarehouse));
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.convertContainer("BOOKING-1"));
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -358,16 +525,50 @@ class InboundPlanServiceTest {
}
@Test
void partialExistingPlansAreRejectedBeforeAnyWrite() {
void partialExistingPlansArePreservedWhileOnlyRemainingOrdersAreCreated() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870", "STANDARD"),
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD")));
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(order("100", 2L, "RK-2", "WH-B")));
when(orderMapper.markPlanned(eq("100"), eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
var result = service.convertContainer("BOOKING-1");
verify(orderMapper, never()).lockOrder(any(), any());
assertEquals(2, result.size());
assertEquals("LIP-1", result.get(0).planNo());
assertEquals(2L, result.get(1).sourceOrderId());
verify(orderMapper, never()).lockOrder("100", 1L);
verify(orderMapper).lockOrder("100", 2L);
verify(planMapper).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(eq("100"), eq(1L), any(),
org.mockito.ArgumentMatchers.anyInt(), any());
verify(orderMapper).markPlanned(eq("100"), eq(2L), any(),
org.mockito.ArgumentMatchers.anyInt(), any());
}
@Test
void partialExistingPlansWithMixedWarehouseFactsCauseZeroNewWrites() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870", "STANDARD"),
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD")));
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot(
"193000000000200003", "US-LAX-01", "洛杉矶仓"));
SourceOrderSnapshot differentWarehouse = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0,
"193000000000200004", null, null, null, "WH-B", "WH-B", "仓B", 1, 0, false, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(differentWarehouse));
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.convertContainer("BOOKING-1"));
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -419,12 +620,14 @@ class InboundPlanServiceTest {
private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) {
return new SourceOrderSnapshot(id, customerId, "MSCU6639870", inboundCode, "PENDING_ASSIGNMENT", null,
BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0);
BigDecimal.ONE, 10, 0, "193000000000200003", "US-LAX-01", "洛杉矶仓", null,
warehouse, warehouse, warehouse, 1, 0);
}
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "MSCU6639870", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
0, "193000000000200003", "US-LAX-01", "洛杉矶仓", null,
warehouse, warehouse, warehouse, 1, 0);
}
}
@@ -9,6 +9,8 @@ import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -82,6 +84,22 @@ class InventorySchemaContractTest {
assertFalse(sql.contains("sku_code"));
}
@Test
void balanceQueriesHideZeroRowsAndMutationsKeepWarehouseDimensionCanonical() throws Exception {
assertTrue(InventoryQueryMapper.BALANCE_FILTER.contains("procurement_packages)>0"));
assertTrue(InventoryQueryMapper.JOINED_BALANCE_FILTER.contains("b.procurement_packages)>0"));
String bindSql = String.join(" ", com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper.class
.getMethod("bindInboundPlan", String.class, String.class, Long.class, String.class,
String.class, java.time.LocalDateTime.class)
.getAnnotation(Update.class).value());
assertTrue(bindSql.contains("overseas_warehouse_id=#{overseasWarehouseId}"));
String deleteSql = String.join(" ", com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper.class
.getMethod("deleteEmptyBalance", String.class, Long.class)
.getAnnotation(Delete.class).value());
assertTrue(deleteSql.contains("in_transit_packages=0"));
assertTrue(deleteSql.contains("NOT EXISTS"));
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.inventory.command;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
@@ -17,6 +19,7 @@ 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.apache.ibatis.annotations.Select;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -57,7 +60,7 @@ class InventoryCommandServiceTest {
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(operations.findIdempotency(any(), any())).thenReturn(null);
lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of());
}
@@ -140,6 +143,7 @@ class InventoryCommandServiceTest {
source.setInTransitPackages(12L);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(target);
when(commands.lockInboundTransitBalances("100", 101L, "IN-CODE-1")).thenReturn(List.of(source));
when(commands.deleteEmptyBalance("100", 201L)).thenReturn(1);
InventoryCommandResponse result = service.stockIn("100", "7",
stockIn("IN-1", "stock-in-cross-warehouse", 12, 24));
@@ -152,6 +156,40 @@ class InventoryCommandServiceTest {
assertEquals(-12L, written.getAllValues().get(0).getDeltaPackages());
assertEquals("STOCK_IN", written.getAllValues().get(1).getBusinessType());
assertEquals("IN_TRANSIT", written.getAllValues().get(1).getStatusFrom());
verify(commands).deleteEmptyBalance("100", 201L);
}
@Test
void receivingWarehouseChangeRebindsOnlyInTransitBalanceAndWritesZeroDeltaAudit() {
InventoryBalance balance = balance(200L, 0);
balance.setInTransitPackages(12L);
when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance));
when(balances.lockDimension("100", "900", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
service.rebindReceivingWarehouse("100", 101L, 900L, "US-NYC-01", "纽约仓",
"admin", "change-1");
assertEquals("900", balance.getOverseasWarehouseId());
assertEquals("US-NYC-01", balance.getOverseasWarehouseCode());
assertEquals("纽约仓", balance.getOverseasWarehouseName());
ArgumentCaptor<InventoryLedger> audit = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(audit.capture());
assertEquals("RECEIVING_WAREHOUSE_CHANGED", audit.getValue().getBusinessType());
assertEquals(0L, audit.getValue().getDeltaPackages());
assertEquals(12L, audit.getValue().getBeforePackages());
assertEquals(12L, audit.getValue().getAfterPackages());
}
@Test
void receivingWarehouseChangeRejectsPhysicalInventoryBeforeAnySnapshotMutation() {
InventoryBalance balance = balance(200L, 1);
when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance));
assertThrows(ResponseStatusException.class, () -> service.rebindReceivingWarehouse(
"100", 101L, 900L, "US-NYC-01", "纽约仓", "admin", "change-physical"));
verify(balances, never()).updateById(any(InventoryBalance.class));
verify(ledgers, never()).insert(any(InventoryLedger.class));
}
@Test
@@ -294,6 +332,97 @@ class InventoryCommandServiceTest {
verify(operations).findIdempotency("200", "platform-hold-1");
}
@Test
void restoresReturnedInventoryFromImmutableShipmentSourceIntoVerifiedTarget() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("restoreReturnedInventory", String.class, String.class,
ReturnedInventoryRestoreRequest.class).getAnnotation(Transactional.class) != null);
InventoryBalance balance = balance(200L, 2);
balance.setOverseasWarehouseId("900");
InventoryPosition source = position(201L, 200L, 0);
when(commands.lockPosition("100", 201L)).thenReturn(source);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L);
when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(1L);
when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0));
when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn(
new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE"));
ReturnedInventoryRestoreResponse result = service.restoreReturnedInventory("100", "7", returnRequest(3));
assertEquals(2, result.beforePackages());
assertEquals(3, result.restoredPackages());
assertEquals(5, result.afterPackages());
assertFalse(result.replayed());
ArgumentCaptor<InventoryPosition> target = ArgumentCaptor.forClass(InventoryPosition.class);
verify(positions).insert(target.capture());
assertEquals("RETURN:EX-1", target.getValue().getBatchNo());
assertEquals("LOC-RETURN", target.getValue().getWarehouseLocationCode());
assertEquals(3L, target.getValue().getAvailablePackages());
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(ledger.capture());
assertEquals("RETURN_STOCK_IN", ledger.getValue().getBusinessType());
assertEquals("RETURN_SOURCE:301:201", ledger.getValue().getSourceEventId());
assertEquals("OUT-1", ledger.getValue().getOutboundOrderNo());
}
@Test
void returnedInventoryReplayDoesNotMutateInventoryTwice() {
InventoryBalance balance = balance(200L, 0);
balance.setOverseasWarehouseId("900");
InventoryPosition source = position(201L, 200L, 0);
when(commands.lockPosition("100", 201L)).thenReturn(source);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L);
when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(0L);
when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0));
when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn(
new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE"));
ReturnedInventoryRestoreRequest request = returnRequest(3);
service.restoreReturnedInventory("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
when(operations.findIdempotency("100", "return-1")).thenReturn(operation.getValue());
ReturnedInventoryRestoreResponse replay = service.restoreReturnedInventory("100", "7", request);
assertTrue(replay.replayed());
verify(positions, times(1)).insert(any(InventoryPosition.class));
verify(ledgers, times(1)).insert(any(InventoryLedger.class));
}
@Test
void rejectsReturnBeyondRemainingImmutableShipmentWithoutInventoryMutation() {
InventoryBalance balance = balance(200L, 0);
balance.setOverseasWarehouseId("900");
when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0));
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(4L);
when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(2L);
assertThrows(ResponseStatusException.class,
() -> service.restoreReturnedInventory("100", "7", returnRequest(3)));
verify(positions, never()).insert(any(InventoryPosition.class));
verify(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class));
verify(ledgers, never()).insert(any(InventoryLedger.class));
}
@Test
void returnRestoreSqlIsBoundToImmutableShipLedgerAndSourceIdentity() throws Exception {
String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages",
String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value());
assertTrue(shippedSql.contains("position_id=#{positionId}"));
assertTrue(shippedSql.contains("outbound_order_no=#{outboundNo}"));
assertTrue(shippedSql.contains("business_type='SHIP'"));
String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages",
String.class, String.class, String.class).getAnnotation(Select.class).value());
assertTrue(restoredSql.contains("source_event_id=#{sourceIdentity}"));
assertTrue(restoredSql.contains("business_type='RETURN_STOCK_IN'"));
}
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null,
@@ -307,10 +436,19 @@ class InventoryCommandServiceTest {
packages, "ORD-1", "EVT-1", key, null);
}
private ReturnedInventoryRestoreRequest returnRequest(long packages) {
return new ReturnedInventoryRestoreRequest("EX-1", "OUT-1", "900", 101L,
List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, "SKU-1", 5)),
List.of(new ReturnedInventoryRestoreRequest.Receipt(301L, 201L, packages,
400L, "PLT-RETURN", 500L, "LOC-RETURN")),
"return-1", List.of("FILE-1"));
}
private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
value.setOverseasWarehouseId("WH-1");
value.setOverseasWarehouseCode("WH1"); value.setOverseasWarehouseName("Warehouse 1");
value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L);
value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1");
value.setAvailablePackages(available);
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.inventory.query;
import com.baiyue.common.web.LongIdJacksonConfiguration;
import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController;
import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InventoryBalanceDetailContractTest {
private static final long BALANCE_ID = 19_000_000_000_000_001L;
private static final long INBOUND_ORDER_ID = 19_000_000_000_000_002L;
@Test
void controllerFreezesDetailPathAndInventoryListPermission() throws Exception {
var method = InventoryQueryController.class.getMethod("balance",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class);
GetMapping mapping = method.getAnnotation(GetMapping.class);
PreAuthorize permission = method.getAnnotation(PreAuthorize.class);
assertNotNull(mapping);
assertEquals("/api/platform/v1/last-leg/inventory/balances/{balanceId}", mapping.value()[0]);
assertNotNull(permission);
assertTrue(permission.value().contains(OverseasPermissions.INVENTORY_LIST));
}
@Test
void openApiDefinesDirectDetailResponseAndStringLongIds() throws Exception {
String yaml;
try (var stream = getClass().getClassLoader().getResourceAsStream("openapi/overseas-business-v1.yaml")) {
assertNotNull(stream);
yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(yaml.contains("/api/platform/v1/last-leg/inventory/balances/{balanceId}:"));
assertTrue(yaml.contains("$ref: '#/components/schemas/InventoryBalanceApiResponse'"));
assertTrue(yaml.contains("id: { type: string, pattern: '^[1-9][0-9]*$'"));
}
@Test
void balanceAndInboundOrderIdsSerializeAsJsonStrings() {
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(
JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)).run(context -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
InventoryBalanceView view = new InventoryBalanceView(BALANCE_ID, "100", "Customer", "900",
"US-LAX-01", "Los Angeles", "800", "ONT8", "ONT8", INBOUND_ORDER_ID,
"IBP-1", "RK-1", "SKU-1", "产品", "Product", 3, 3, 0, 0, 0, 0, 0, 0, null);
JsonNode json = mapper.readTree(mapper.writeValueAsString(view));
assertEquals(Long.toString(BALANCE_ID), json.path("id").textValue());
assertEquals(Long.toString(INBOUND_ORDER_ID), json.path("inboundOrderId").textValue());
});
}
}
@@ -34,6 +34,20 @@ class InventoryQueryControllerTest {
verify(service).balances(eq("10001"), eq(query));
}
@Test
void platformBalanceDetailQueriesByIdWithoutPaging() {
controller.balance(user(0L, "PLATFORM"), 9007199254740993L, null);
verify(service).balance(null, 9007199254740993L);
}
@Test
void platformBalanceDetailUsesExplicitCustomerScope() {
controller.balance(user(0L, "PLATFORM"), 99L, " 10001 ");
verify(service).balance("10001", 99L);
}
@Test
void merchantCannotUsePlatformInventoryController() {
assertThrows(ResponseStatusException.class,
@@ -19,6 +19,7 @@ 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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -115,6 +116,32 @@ class InventoryQueryServiceTest {
verify(mapper).countBalances(null, query);
}
@Test
void platformBalanceDetailReadsOneVisibleBalanceWithoutPaging() {
InventoryBalance balance = new InventoryBalance();
balance.setId(99L);
balance.setCustomerId("200");
balance.setAvailablePackages(3L);
when(mapper.visibleBalance(99L)).thenReturn(balance);
var result = service.balance(null, 99L);
assertEquals(99L, result.id());
assertEquals("200", result.customerId());
assertEquals(3L, result.totalPackages());
verify(mapper).visibleBalance(99L);
verify(mapper, never()).countBalances(any(), any());
verify(mapper, never()).balances(any(), any(), anyLong(), anyLong());
}
@Test
void scopedBalanceDetailDoesNotLeakAnotherCustomer() {
assertThrows(ResponseStatusException.class, () -> service.balance("100", 99L));
verify(mapper).ownedVisibleBalance("100", 99L);
verify(mapper, never()).visibleBalance(99L);
}
@Test
void unknownStatusIsRejectedBeforeDatabaseQuery() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED");
@@ -87,6 +87,8 @@ class LocalLooseboardServiceTest {
assertEquals("SHARED_TRUCK", detail.summary().transportMode());
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
&& value.getInboundCode() == null
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.mapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LastlegOrderWarehouseQueryContractTest {
@Test
void warehouseFilterUsesOnlyFrozenReceivingWarehouseCodeAndName() {
String sql = LastlegOrderMapper.WAREHOUSE_FILTER_SQL;
assertTrue(sql.contains("LOWER(TRIM(overseas_warehouse_code))"));
assertTrue(sql.contains("LOWER(TRIM(overseas_warehouse_name))"));
assertTrue(sql.contains("LIKE CONCAT('%',LOWER({0}),'%')"));
assertFalse(sql.contains("overseas_warehouse_id"));
assertFalse(sql.contains("transfer_warehouse"));
}
}
@@ -35,8 +35,14 @@ class MerchantOverseasOpenApiContractTest {
.contains("#/components/schemas/TruckDetailResponse")
.contains("#/components/schemas/LooseboardDetailResponse")
.contains("#/components/schemas/InboundPlanDetailResponse")
.contains("overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }")
.contains("overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }")
.contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE")
.contains("version 必填").contains("幂等冲突");
assertThat(yaml).contains("/api/platform/v1/last-leg/inbound-plans:")
.contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:")
.contains("overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实")
.contains("客户范围只取认证 Token,不接收 customerId");
}
private static String pathBlock(String yaml, String path) {
@@ -74,13 +74,15 @@ class PdaOperationsOpenApiContractTest {
"/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/resolve/{businessNo}:",
"/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(32, paths.size());
assertEquals(33, paths.size());
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
assertTrue(yaml.contains("Warehouse 权威实物托盘码"));
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
assertEquals(3, occurrences(yaml, "body.code=A0404"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
@@ -28,6 +28,8 @@ class RelabelContractTest {
void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8).toLowerCase();
for (String schema : new String[] { baseline, migration }) {
assertTrue(schema.contains("ll_relabel_application"));
assertTrue(schema.contains("ll_relabel_item"));
@@ -36,6 +38,13 @@ class RelabelContractTest {
assertTrue(schema.contains("idempotency_key"));
assertTrue(schema.contains("request_hash"));
}
for (String schema : new String[] { baseline, migration, deployment }) {
assertTrue(schema.contains("ll_relabel_item"));
assertTrue(schema.contains("remark"));
assertTrue(schema.contains("carton_mark"));
assertTrue(schema.contains("fba_carton_mark"));
assertTrue(schema.contains("destination_warehouse_id"));
}
}
@Test
@@ -38,6 +38,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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;
@@ -119,9 +120,9 @@ class RelabelApplicationServiceTest {
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}"));
assertEquals(tailOrder.getValue().getInboundCode() + ":WH-1", tailOrder.getValue().getSourceInboundId());
assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo());
assertTrue(tailOrder.getValue().getInboundCode().startsWith("RL-"));
assertNull(tailOrder.getValue().getInboundCode());
assertEquals(application.getValue().getRelabelNo() + ":WH-1", tailOrder.getValue().getSourceInboundId());
assertEquals(tailOrder.getValue().getRelabelOrderNo(), application.getValue().getRelabelNo());
assertEquals(10, tailOrder.getValue().getPackageCount());
assertEquals(6, application.getValue().getNotifiedPackageCount());
assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
@@ -17,7 +17,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class SchemaChineseCommentContractTest {
private static final Set<String> MODULE_BASELINE_ONLY_TABLES = Set.of(
"ll_us_truck_order_operation", "ll_local_looseboard_operation");
"ll_us_truck_order_operation", "ll_local_looseboard_operation",
"ll_receiving_warehouse_operation");
private static final Set<String> MODULE_BASELINE_ONLY_COLUMNS = Set.of(
"ll_order.overseas_warehouse_code", "ll_order.receiving_warehouse_version",
"ll_receiving_container_session.overseas_warehouse_code");
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
private static final Pattern TABLE = Pattern.compile(
@@ -138,6 +142,9 @@ class SchemaChineseCommentContractTest {
continue;
}
for (String column : table.columns()) {
if (MODULE_BASELINE_ONLY_COLUMNS.contains(table.name() + "." + column)) {
continue;
}
boolean repaired = migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'");
boolean addedWithComment = hasCommentedAdd(migration, column)
@@ -24,6 +24,11 @@ class Phase5AdminPermissionContractTest {
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("page"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("resolve"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_CREATE), permissions.get("create"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("detail"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("uploadEvidence"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("startProcessing"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("complete"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("cancel"));
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("returnOptions"));
assertEquals(authority(OverseasPermissions.DELIVERY_RETURN), permissions.get("markReturn"));
}
@@ -49,6 +54,7 @@ class Phase5AdminPermissionContractTest {
OverseasPermissions.DISPATCH_LIST,
OverseasPermissions.EXCEPTION_ORDER_LIST,
OverseasPermissions.EXCEPTION_ORDER_CREATE,
OverseasPermissions.EXCEPTION_ORDER_PROCESS,
OverseasPermissions.DELIVERY_RETURN,
OverseasPermissions.DELIVERY_STATUS,
OverseasPermissions.CUSTOMER_VISIT_LIST,
@@ -59,6 +65,10 @@ class Phase5AdminPermissionContractTest {
for (String path : new String[] {
"/api/platform/v1/last-leg/dispatches:",
"/api/platform/v1/last-leg/exception-orders:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:",
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:",
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:",
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:",
"/api/platform/v1/last-leg/customer-visits:",
@@ -18,12 +18,15 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ContainerPlanProgressContractTest {
@Test
void responseFieldsAndServerAggregateStayFrozen() throws Exception {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "totalOrderCount", "inboundCodeCount",
assertEquals(List.of("sourceContainerBookingId", "containerNo", "overseasWarehouseId",
"overseasWarehouseCode", "overseasWarehouseName", "assignmentVersion",
"canChangeReceivingWarehouse", "receivingWarehouseLockReason", "totalOrderCount", "inboundCodeCount",
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount",
"plannedArrivalAt", "allPlansReady", "blockingReason"),
Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents())
@@ -38,8 +41,8 @@ class ContainerPlanProgressContractTest {
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("left join ll_inbound_plan"));
assertTrue(sql.contains("o.estimated_warehouse_at"));
assertTrue(sql.contains("having count(distinct case when p.id is not null"));
assertTrue(sql.contains("&lt; count(distinct nullif(trim(o.inbound_code),''))"));
assertTrue(sql.contains("group by o.source_container_booking_id"));
assertFalse(sql.contains("having "));
assertTrue(sql.contains("progress.plannedarrivalat asc"));
assertTrue(sql.contains("progress.sourcecontainerbookingid asc"));
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
@@ -56,7 +59,12 @@ class ContainerPlanProgressContractTest {
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:"));
assertTrue(openApi.contains("ContainerPlanProgressSummary:"));
assertTrue(openApi.contains("allPlansReady:"));
assertTrue(openApi.contains("仅返回仍有入仓码待生成有效独立计划的柜"));
assertTrue(openApi.contains("返回全部稳定来源柜"));
assertTrue(openApi.contains("全部入仓码均已生成有效独立计划的柜仍保留"));
assertTrue(openApi.contains("权限 platform:overseas-warehouse:last-mile-order:list"));
assertTrue(openApi.contains("权限 platform:overseas-warehouse:last-mile-order:edit"));
assertFalse(openApi.contains("platform:overseas-warehouse:order:list"));
assertFalse(openApi.contains("platform:overseas-warehouse:order:edit"));
assertTrue(openApi.contains("ll_order.estimated_warehouse_at"));
assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
@@ -75,8 +83,8 @@ class ContainerPlanProgressContractTest {
assertTrue(sql.contains("source_container_booking_id<>''"));
assertTrue(sql.contains("LEFT JOIN ll_inbound_plan"));
assertTrue(sql.contains("HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL"));
assertTrue(sql.contains("< COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),''))"));
assertTrue(sql.contains("GROUP BY o.source_container_booking_id"));
assertFalse(sql.contains("HAVING "));
assertTrue(!sql.contains("&lt;"));
}
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.JsonNode;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class InboundOrderDetainedOutboxServiceTest {
@Mock private LastlegEventOutboxMapper mapper;
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private InboundOrderDetainedOutboxService service;
@BeforeEach
void setUp() {
service = new InboundOrderDetainedOutboxService();
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@Test
void localSyntheticSourceReferenceIsNotWrittenAsEventCausationId() throws Exception {
LastlegOrder order = new LastlegOrder();
order.setId(101L);
order.setCustomerId("201");
order.setOrderNo("WT-WH-20260907-972865");
order.setSourceInboundId("WAREHOUSE_TRANSFER:WT-WH-20260907-778561");
order.setInboundCode("NYEXX2609070253");
order.setSourceEventId("WAREHOUSE_TRANSFER:WT-WH-20260907-778561");
service.publish(order, true, "等待质检", "301", "operator", Instant.parse("2026-09-07T16:00:00Z"), 1L);
ArgumentCaptor<LastlegEventOutbox> captor = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(captor.capture());
JsonNode envelope = objectMapper.readTree(captor.getValue().getPayloadJson());
assertThat(envelope.path("causationId").isNull()).isTrue();
assertThat(envelope.path("aggregateId").asText()).isEqualTo(order.getSourceInboundId());
}
}
@@ -0,0 +1,161 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import com.baiyue.lastleg.entity.LastlegOrderHoldHistory;
import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderCommandServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderCommandRecordMapper commands;
@Mock LastlegOrderHoldHistoryMapper holdHistory;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Mock LastlegOrderOperationLogMapper operationLogs;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
LastlegOrderCommandService service;
@BeforeEach
void setUp() {
service = new LastlegOrderCommandService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "holdHistory", holdHistory);
ReflectionTestUtils.setField(service, "manualNodes", manualNodes);
ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory);
ReflectionTestUtils.setField(service, "operationLogs", operationLogs);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
}
@Test
void holdsOnlySelectedOrderWithVersionAndImmutableHistory() {
LastlegOrder order = order(11L, 3);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3),
any(LocalDateTime.class))).thenReturn(1);
var response = service.hold("100", 11L, "hold-11",
new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 3), "900", "管理员");
assertTrue(response.held());
assertTrue(response.chargeable());
assertEquals(4, response.version());
verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any());
verify(holdHistory).insert(any(LastlegOrderHoldHistory.class));
verify(commands).insert(any(LastlegOrderCommandRecord.class));
verify(orders, never()).lockContainerOrders(any());
}
@Test
void rejectsStaleHoldWithoutAnyWrite() {
LastlegOrder order = order(11L, 4);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L,
"hold-stale", new LastlegOrderCommandDtos.HoldRequest(true, false, "待核对", 3), "900", "管理员"));
assertEquals(409, error.getStatusCode().value());
verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(),
nullable(String.class), anyInt(), any(LocalDateTime.class));
verify(commands, never()).insert(any(LastlegOrderCommandRecord.class));
}
@Test
void savesExactEightRowManualSnapshotInFrozenDisplayOrder() {
LastlegOrder order = order(12L, 0);
when(orders.selectById(12L)).thenReturn(order);
when(orders.lockById("100", 12L)).thenReturn(order);
when(orders.updateManualNodes(eq("100"), eq(12L), any(), any(), any(), eq(0), any())).thenReturn(1);
LocalDateTime filled = LocalDateTime.of(2026, 9, 8, 10, 0);
List<LastlegOrderCommandDtos.ManualNode> nodes = List.of(
node("UNLOADED", null), node("PICKUP_APPOINTED", null),
node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null), node("OVERSEAS_CUSTOMS_COMPLETED", null),
node("ESTIMATED_ARRIVAL", filled), node("ACTUAL_DEPARTURE", null),
node("ESTIMATED_DEPARTURE", null), node("DOMESTIC_CUSTOMS_COMPLETED", null));
var response = service.saveManualNodes("100", 12L, "nodes-12",
new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员");
assertTrue(response.manualOverrideEnabled());
assertEquals(8, response.nodes().size());
assertEquals("DOMESTIC_CUSTOMS_COMPLETED", response.nodes().get(0).nodeCode());
assertEquals("UNLOADED", response.nodes().get(7).nodeCode());
ArgumentCaptor<LastlegOrderManualNode> rows = ArgumentCaptor.forClass(LastlegOrderManualNode.class);
verify(manualNodes, org.mockito.Mockito.times(8)).insert(rows.capture());
assertEquals(8, rows.getAllValues().size());
verify(manualNodeHistory).insert(any(LastlegOrderManualNodeHistory.class));
}
@Test
void rejectsIncompleteOrFullyEmptyManualSnapshot() {
LastlegOrder order = order(12L, 0);
when(orders.selectById(12L)).thenReturn(order);
when(orders.lockById("100", 12L)).thenReturn(order);
List<LastlegOrderCommandDtos.ManualNode> nodes = List.of(
node("DOMESTIC_CUSTOMS_COMPLETED", null), node("ESTIMATED_DEPARTURE", null),
node("ACTUAL_DEPARTURE", null), node("ESTIMATED_ARRIVAL", null),
node("OVERSEAS_CUSTOMS_COMPLETED", null), node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null),
node("PICKUP_APPOINTED", null), node("UNLOADED", null));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.saveManualNodes("100", 12L, "nodes-empty",
new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员"));
assertEquals(400, error.getStatusCode().value());
verify(orders, never()).updateManualNodes(anyString(), anyLong(), nullable(LocalDateTime.class),
nullable(LocalDateTime.class), nullable(LocalDateTime.class), anyInt(), any(LocalDateTime.class));
}
private LastlegOrderCommandDtos.ManualNode node(String code, LocalDateTime time) {
return new LastlegOrderCommandDtos.ManualNode(code, time);
}
private LastlegOrder order(long id, int version) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
order.setCustomerId("100");
order.setOrderNo("LL-" + id);
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setDetained(false);
order.setVersion(version);
return order;
}
}
@@ -14,6 +14,9 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -28,6 +31,9 @@ import java.time.LocalDateTime;
import java.math.BigDecimal;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -37,6 +43,7 @@ import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@@ -48,10 +55,13 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderFeeMapper fees;
@Mock LastlegOrderOperationLogMapper operations;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
@Mock InventoryCommandService inventory;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway;
LastlegOrderService service;
@BeforeEach
@@ -62,6 +72,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "feeMapper", fees);
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses);
lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation ->
@@ -70,6 +81,56 @@ class LastlegOrderServiceTest {
"US", "CA"));
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations);
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
}
@Test
void changesReceivingWarehouseForAllPlansReadyContainerBeforePdaSession() {
LastlegOrder first = order(1L, "100", "PENDING_ASSIGNMENT", null);
LastlegOrder second = order(2L, "200", "PENDING_ASSIGNMENT", null);
for (LastlegOrder value : List.of(first, second)) {
value.setReceivingWarehouseVersion(0); value.setOverseasWarehouseId("193000000000200003");
value.setOverseasWarehouseCode("US-LAX-01"); value.setOverseasWarehouseName("洛杉矶仓");
}
var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900",
"US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 2, 2, 0,
null, true, null);
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second));
when(warehouseResourceGateway.requireActiveSharedWarehouse(900L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(900L, "US-NYC-01", "纽约仓", "ACTIVE"));
when(orders.updateContainerReceivingWarehouse("BOOKING-1", "900", "US-NYC-01", "纽约仓", 0))
.thenReturn(2);
when(orders.findContainerProgress("BOOKING-1")).thenReturn(updated);
var result = service.changeReceivingWarehouse("BOOKING-1", "change-1",
new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, "改派纽约"), "admin");
assertEquals("US-NYC-01", result.overseasWarehouseCode());
assertTrue(result.allPlansReady());
assertTrue(result.canChangeReceivingWarehouse());
verify(inventory).rebindReceivingWarehouse("100", 1L, 900L,
"US-NYC-01", "纽约仓", "admin", "change-1");
verify(inventory).rebindReceivingWarehouse("200", 2L, 900L,
"US-NYC-01", "纽约仓", "admin", "change-1");
verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L);
verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class));
}
@Test
void permanentlyLocksReceivingWarehouseAfterPdaSessionExists() {
LastlegOrder order = order(1L, "100", "PENDING_ASSIGNMENT", null);
order.setReceivingWarehouseVersion(0); order.setOverseasWarehouseId("193000000000200003");
order.setOverseasWarehouseCode("US-LAX-01"); order.setOverseasWarehouseName("洛杉矶仓");
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(order));
when(orders.countReceivingSessions("BOOKING-1")).thenReturn(1L);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.changeReceivingWarehouse("BOOKING-1", "change-2",
new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, null), "admin"));
assertEquals(409, error.getStatusCode().value());
verifyNoInteractions(warehouseResourceGateway);
}
@Test
@@ -81,6 +142,7 @@ class LastlegOrderServiceTest {
when(histories.selectList(any())).thenReturn(List.of());
when(fees.selectList(any())).thenReturn(List.of());
when(operations.selectList(any())).thenReturn(List.of());
when(manualNodes.selectList(any())).thenReturn(List.of());
var response = service.detail("100", 11L);
@@ -224,6 +286,26 @@ class LastlegOrderServiceTest {
assertEquals(25, page.getValue().getSize());
}
@Test
void pageWarehouseFilterMatchesFrozenWarehouseName() {
assertWarehouseFilter("洛杉矶仓", "洛杉矶仓", true);
}
@Test
void pageWarehouseFilterMatchesFrozenWarehouseCodeIgnoringCaseAndSpaces() {
assertWarehouseFilter(" us-lax-01 ", "us-lax-01", true);
}
@Test
void pageWarehouseFilterMatchesPartialFrozenWarehouseCode() {
assertWarehouseFilter("LAX-0", "LAX-0", true);
}
@Test
void pageWarehouseFilterReturnsEmptyPageWhenNothingMatches() {
assertWarehouseFilter("WAREHOUSE-NOT-FOUND", "WAREHOUSE-NOT-FOUND", false);
}
@Test
void containerProgressPageUsesServerSideAggregatePagination() {
var query = new LastlegOrderRequests.ContainerProgressQuery(2, 10, "CONT-", null, null);
@@ -269,20 +351,65 @@ class LastlegOrderServiceTest {
}
@Test
void containerProgressDetailFailsClosedWhenContainerNumberFactHasDrifted() {
void containerProgressDetailKeepsInvalidHistoricalContainerReadableAndBlocked() {
var drifted = new ContainerPlanProgressSummary("BOOKING-1", null, 2, 2, 0,
2, 2, 0, LocalDateTime.of(2026, 10, 3, 9, 0), false, "柜号缺失或不一致");
when(orders.findContainerProgress("BOOKING-1")).thenReturn(drifted);
when(orders.findContainerProgressItems("BOOKING-1")).thenReturn(List.of());
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.containerProgressDetail("BOOKING-1"));
var result = service.containerProgressDetail("BOOKING-1");
assertEquals(409, error.getStatusCode().value());
verify(orders, never()).findContainerProgressItems(any());
assertFalse(result.summary().allPlansReady());
assertFalse(result.summary().canChangeReceivingWarehouse());
assertEquals("来源柜真实集装箱号缺失、格式无效或存在漂移",
result.summary().blockingReason());
assertEquals(result.summary().blockingReason(), result.summary().receivingWarehouseLockReason());
verify(orders).findContainerProgressItems("BOOKING-1");
}
@Test
void containerProgressPageKeepsMissingWarehouseSnapshotFromBreakingHealthyContainers() {
var query = new LastlegOrderRequests.ContainerProgressQuery(1, 10, null, null, null);
var healthy = summary(false, 3, "存在3票尚未生成有效入库计划");
var missingWarehouse = new ContainerPlanProgressSummary("BOOKING-2", "MSCU6639870",
null, null, null, 0, true, null, 2, 2, 0, 0, 0, 2,
LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划");
when(orders.countContainerProgress(query)).thenReturn(2L);
when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(healthy, missingWarehouse));
var result = service.containerProgressPage(query);
assertEquals(2, result.records().size());
assertSame(healthy, result.records().get(0));
assertEquals("柜内接柜仓业务快照不一致", result.records().get(1).blockingReason());
assertFalse(result.records().get(1).allPlansReady());
assertTrue(result.records().get(1).canChangeReceivingWarehouse());
}
@Test
void containerProgressPageKeepsReadyAndPdaLockedContainersVisible() {
var query = new LastlegOrderRequests.ContainerProgressQuery(1, 10, null, null, null);
var ready = summary(true, 0, null);
var locked = new ContainerPlanProgressSummary("BOOKING-2", "TSTU0000061",
"193000000000200003", "US-LAX-01", "洛杉矶仓", 4, false,
"海外PDA已建立接柜会话,接柜仓已永久锁定", 1, 1, 0, 1, 1, 0,
LocalDateTime.of(2026, 10, 4, 9, 0), true, null);
when(orders.countContainerProgress(query)).thenReturn(2L);
when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(ready, locked));
var result = service.containerProgressPage(query);
assertEquals(List.of(ready, locked), result.records());
assertTrue(result.records().get(0).allPlansReady());
assertTrue(result.records().get(0).canChangeReceivingWarehouse());
assertFalse(result.records().get(1).canChangeReceivingWarehouse());
assertEquals("海外PDA已建立接柜会话,接柜仓已永久锁定",
result.records().get(1).receivingWarehouseLockReason());
}
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", 15, 15, 0,
return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "193000000000200003",
"US-LAX-01", "洛杉矶仓", 0, true, null, 15, 15, 0,
15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
}
@@ -306,6 +433,31 @@ class LastlegOrderServiceTest {
assertEquals(List.of("INBOUND-2", "INBOUND-3"), result.records().get(0).palletMergeInboundNos());
}
@Test
void pageDoesNotExposeBusinessOrderNumbersAsInboundCodes() {
LastlegOrder relabel = order(11L, "100", "PENDING_ASSIGNMENT", null);
relabel.setOrderType("RELABEL_APPLICATION");
relabel.setInboundCode("RL-WH-20260906-701570");
LastlegOrder looseboard = order(12L, "100", "PENDING_ASSIGNMENT", null);
looseboard.setOrderType("LOCAL_LOOSEBOARD");
looseboard.setInboundCode("LB-WH-20260906-187457");
LastlegOrder firstleg = order(13L, "100", "PENDING_ASSIGNMENT", null);
firstleg.setOrderType("FIRST_LEG");
firstleg.setInboundCode("NYEXX2609060063");
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10);
resultPage.setRecords(List.of(relabel, looseboard, firstleg));
resultPage.setTotal(3);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null, null);
var result = service.page(null, query);
assertNull(result.records().get(0).inboundCode());
assertNull(result.records().get(1).inboundCode());
assertEquals("NYEXX2609060063", result.records().get(2).inboundCode());
}
@Test
void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() {
LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null);
@@ -352,4 +504,30 @@ class LastlegOrderServiceTest {
return order;
}
@SuppressWarnings({"rawtypes", "unchecked"})
private void assertWarehouseFilter(String input, String normalized, boolean matched) {
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10);
LastlegOrder row = order(11L, "100", "PENDING_ASSIGNMENT", null);
row.setOverseasWarehouseCode("US-LAX-01");
row.setOverseasWarehouseName("洛杉矶仓");
resultPage.setRecords(matched ? List.of(row) : List.of());
resultPage.setTotal(matched ? 1 : 0);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, input, null, null, null, null, null));
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LastlegOrder>> filter =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(orders).selectPage(any(), filter.capture());
var wrapper = (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LastlegOrder>) filter.getValue();
String warehouseSql = wrapper.getExpression().getNormal().getSqlSegment();
assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_code))"));
assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_name))"));
assertTrue(wrapper.getParamNameValuePairs().containsValue(normalized),
wrapper.getParamNameValuePairs().toString());
assertEquals(matched ? 1 : 0, result.total());
assertEquals(matched ? 1 : 0, result.records().size());
}
}
@@ -5,7 +5,7 @@ class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
@@ -27,6 +27,12 @@ class LoadingCompletedConsumerTest {
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
service.consume(event());
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),captured.capture());
assertTrue(captured.getAllValues().stream().allMatch(value->"193000000000200003".equals(value.overseasWarehouseId())&&"US-LAX-01".equals(value.overseasWarehouseCode())&&"洛杉矶仓".equals(value.overseasWarehouseName())));
}
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));