暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void customerCannotOverrideItsTenantScope() {
controller.list(user(100L, "MERCHANT"), "200", query);
verify(service).page(eq("100"), eq(query));
}
@Test
void platformOperatorMustExplicitlyChooseCustomerTenant() {
assertThrows(ResponseStatusException.class,
() -> controller.list(user(0L, "PLATFORM"), null, query));
}
@Test
void platformOperatorCanQueryExplicitCustomerTenant() {
controller.list(user(0L, "PLATFORM"), "200", query);
verify(service).page(eq("200"), eq(query));
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of());
}
private LastlegOrderController controller() {
LastlegOrderController controller = new LastlegOrderController();
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
ReflectionTestUtils.setField(controller, "orderService", service);
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
return controller;
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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 DeliverySchemaContractTest {
@Test
void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception {
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(schema.contains("create table if not exists ll_outbound_order"));
assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'"));
assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)"));
assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)"));
assertTrue(schema.contains("destination_warehouse_code varchar(128) null"));
assertTrue(schema.contains("order_refs varchar(2000) null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
assertTrue(schema.contains("create table if not exists ll_delivery_order"));
assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'"));
assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_delivery_outbound (customer_id,outbound_order_id)"));
}
@Test
void deliveryPersistenceEntitiesUseSnowflakeIds() throws Exception {
for (Class<?> type : new Class<?>[] { OutboundOrder.class, OutboundPalletItem.class, DeliveryOrder.class }) {
TableId id = type.getDeclaredField("id").getAnnotation(TableId.class);
assertNotNull(id, type.getSimpleName());
assertEquals(IdType.ASSIGN_ID, id.type(), type.getSimpleName());
}
}
@Test
void incrementalMigrationAddsTransferTablesAndIsRerunnable() throws Exception {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(migration.contains("2026-08-17 / 014"));
assertTrue(migration.contains("create table if not exists ll_outbound_order"));
assertTrue(migration.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(migration.contains("create table if not exists ll_delivery_order"));
}
}
@@ -0,0 +1,297 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
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.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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DeliveryTransferServiceTest {
@Mock DispatchOrderMapper dispatchOrders;
@Mock DispatchItemMapper dispatchItems;
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundPallets;
@Mock DeliveryOrderMapper deliveryOrders;
@Mock InboundPlanPalletAllocationMapper planAllocations;
@Mock InboundPlanLineMapper planLines;
@Mock LastlegOrderMapper tailOrders;
@Mock LastlegOrderStatusHistoryMapper histories;
private DeliveryTransferService service;
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders);
ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
}
@Test
void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12),
item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8)
));
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer("transfer-101", "ready"));
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
ArgumentCaptor<DeliveryOrder> delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
ArgumentCaptor<OutboundPalletItem> pallets = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundOrders).insert(outbound.capture());
verify(deliveryOrders).insert(delivery.capture());
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture());
assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus());
assertEquals(2, outbound.getValue().getPalletCount());
assertEquals(20, outbound.getValue().getPackageCount());
assertEquals("transfer-101", outbound.getValue().getIdempotencyKey());
assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT"));
assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId());
assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus());
assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL"));
assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream()
.map(OutboundPalletItem::getDestinationWarehouseCode).toList());
assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream()
.map(OutboundPalletItem::getInboundCode).toList());
assertEquals(20, result.packageCount());
assertEquals(2, result.palletCount());
assertTrue(!result.replayed());
}
@Test
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer(" transfer-101 ", null));
assertTrue(result.replayed());
assertEquals("OUT-301", result.outboundNo());
assertEquals("DEL-401", result.deliveryNo());
verify(outboundOrders, never()).insert((OutboundOrder) any());
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
}
@Test
void idempotencyKeyCannotBeReusedForAnotherDispatch() {
DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A");
OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null)));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void concurrentDuplicateInsertReturnsWinningDocuments() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, winner, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer("transfer-101", null));
assertTrue(result.replayed());
assertEquals("OUT-301", result.outboundNo());
assertEquals("DEL-401", result.deliveryNo());
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
}
@Test
void directDispatchOrderReferenceIsBoundToCreatedDocuments() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
source.setOrderId(901L);
LastlegOrder tailOrder = new LastlegOrder();
tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source));
when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder));
service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null));
assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus());
assertNotNull(tailOrder.getOutboundOrderNo());
assertNotNull(tailOrder.getDeliveryOrderNo());
verify(tailOrders).updateById(tailOrder);
verify(histories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void transferRejectsAPlannedPalletThatHasNotBeenReceived() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12);
planned.setSourceStage("PLAN");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null)));
assertEquals(409, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("received"));
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8);
when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") Page<OutboundPalletItem> page = invocation.getArgument(0);
page.setRecords(List.of(first, second));
page.setTotal(2);
return page;
});
DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
PageResult<DeliveryResponses.PalletRow> pallets = detail.pallets();
assertEquals(2, pallets.total());
assertEquals(2, pallets.records().size());
assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode());
assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName());
assertEquals(12, pallets.records().get(0).packageCount());
assertEquals("IN-A", pallets.records().get(0).inboundCode());
assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode());
assertEquals(8, pallets.records().get(1).packageCount());
}
@Test
void detailLookupCannotCrossTenantBoundary() {
when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)));
assertEquals(404, exception.getStatusCode().value());
verify(outboundOrders).findByNo("TENANT-B", "OUT-301");
verify(deliveryOrders, never()).lockByOutbound(any(), any());
verify(outboundPallets, never()).selectPage(any(), any());
}
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {
DispatchOrder value = new DispatchOrder();
value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART");
return value;
}
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
String destinationName, String inboundCode, int packages) {
DispatchItem value = new DispatchItem();
value.setId(id); value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo);
value.setDestinationWarehouseId("DEST-" + destinationCode);
value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName);
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
value.setSourceStage("RECEIVED");
return value;
}
private static OutboundOrder outbound(Long id, Long dispatchId, String dispatchNo, String outboundNo,
String idempotencyKey) {
OutboundOrder value = new OutboundOrder();
value.setId(id); value.setCustomerId("TENANT-A"); value.setDispatchOrderId(dispatchId);
value.setDispatchNo(dispatchNo); value.setOutboundNo(outboundNo); value.setStatus("WAIT_OUTBOUND");
value.setOverseasWarehouseId("WH-US-1"); value.setPalletCount(2); value.setPackageCount(20);
value.setIdempotencyKey(idempotencyKey); value.setCreatedAt(LocalDateTime.now());
return value;
}
private static DeliveryOrder delivery(Long id, OutboundOrder outbound, String deliveryNo) {
DeliveryOrder value = new DeliveryOrder();
value.setId(id); value.setCustomerId(outbound.getCustomerId()); value.setDeliveryNo(deliveryNo);
value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo());
value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo());
value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY");
value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount());
value.setCreatedAt(LocalDateTime.now());
return value;
}
private static OutboundPalletItem pallet(Long warehousePalletId, String palletNo, String destinationCode,
String destinationName, String inboundCode, int packages) {
OutboundPalletItem value = new OutboundPalletItem();
value.setId(warehousePalletId + 1000); value.setCustomerId("TENANT-A"); value.setOutboundOrderId(301L);
value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo);
value.setDestinationWarehouseId("DEST-" + destinationCode);
value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName);
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
return value;
}
}
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import org.mybatis.spring.annotation.MapperScan;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.server.ResponseStatusException;
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;
@SpringBootTest(classes = CargoDiscardMysqlAcceptanceTest.AcceptanceApplication.class, properties = {
"spring.sql.init.mode=never",
"spring.cloud.nacos.discovery.enabled=false",
"spring.cloud.nacos.discovery.register-enabled=false"
})
@EnabledIfSystemProperty(named = "cargo.discard.acceptance", matches = "true")
class CargoDiscardMysqlAcceptanceTest {
private static final String CUSTOMER = "990001";
@Autowired private JdbcTemplate jdbc;
@Autowired private CargoDiscardService service;
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({CargoDiscardService.class, CargoDiscardInventoryAdjustmentService.class})
@MapperScan({
"com.baiyue.lastleg.mapper",
"com.baiyue.lastleg.discard.mapper",
"com.baiyue.lastleg.dropship.mapper"
})
static class AcceptanceApplication {
}
@Test
void createApprovalRejectionPendingAndInsufficientStockScenarios() {
resetAcceptanceTenant();
seedOrder(990001001L, "RK-ACCEPT-APPROVE", "JF-ACCEPT-001", 20);
seedProfile(990001101L, 990001001L, "PALLET-A");
seedSku(990001201L, 990001001L, 990001101L, "SKU-BOX-RED", "红色折叠收纳箱", 12);
seedSku(990001202L, 990001001L, 990001101L, "SKU-RACK-KITCHEN", "厨房置物架", 8);
seedCartons(990001001L, 990001101L, 990001301L, "PALLET-A", "SKU-BOX-RED", "RED", 12);
seedCartons(990001001L, 990001101L, 990001401L, "PALLET-A", "SKU-RACK-KITCHEN", "RACK", 8);
seedOrder(990001002L, "RK-ACCEPT-REJECT", "JF-ACCEPT-002", 10);
seedProfile(990001102L, 990001002L, "PALLET-B");
seedSku(990001203L, 990001002L, 990001102L, "SKU-LAMP-WHITE", "白色台灯", 10);
seedCartons(990001002L, 990001102L, 990001501L, "PALLET-B", "SKU-LAMP-WHITE", "LAMP", 10);
seedOrder(990001003L, "RK-ACCEPT-PENDING", "JF-ACCEPT-003", 7);
seedProfile(990001103L, 990001003L, "PALLET-C");
seedSku(990001204L, 990001003L, 990001103L, "SKU-MAT-BLUE", "蓝色瑜伽垫", 7);
seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7);
var approveRequest = new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3),
new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)),
"ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃");
var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest);
assertEquals(20L, skuPackages(990001001L));
approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer",
new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实"));
assertEquals(CargoDiscardService.APPROVED, approved.summary().status());
assertEquals(15L, skuPackages(990001001L));
var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)),
"ACCEPT-DISCARD-REJECT", "验收:外箱可修复"));
assertEquals(10L, skuPackages(990001002L));
rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer",
new CargoDiscardRequests.Review("REJECT", "复核后无需废弃"));
assertEquals(CargoDiscardService.REJECTED, rejected.summary().status());
assertEquals(10L, skuPackages(990001002L));
var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)),
"ACCEPT-DISCARD-PENDING", "验收:等待仓库审核"));
assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status());
assertEquals(7L, skuPackages(990001003L));
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator",
new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)),
"ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败")));
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'"));
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='REJECTED'"));
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='PENDING_REVIEW'"));
assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001001 AND status='DISCARDED'"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001002 AND status='DISCARD_PENDING'"));
assertEquals(2, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001003 AND status='DISCARD_PENDING'"));
assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'"));
assertEquals(5, count("SELECT COUNT(DISTINCT idempotency_key) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'"));
String audit = jdbc.queryForObject("SELECT remark FROM ll_cargo_discard_status_history WHERE customer_id=? " +
"AND to_status='APPROVED'", String.class, CUSTOMER);
assertTrue(audit.contains("inventoryBefore=20, discarded=5, inventoryAfter=15"));
}
private void resetAcceptanceTenant() {
jdbc.update("DELETE FROM ll_dropship_carton_ledger WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_status_history WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_carton WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_item WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_application WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_dropship_carton WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_dropship_sku_inventory WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_dropship_order_profile WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_order WHERE customer_id=?", CUSTOMER);
}
private void seedOrder(long id, String inboundCode, String orderNo, int packages) {
jdbc.update("INSERT INTO ll_order(id,order_no,customer_id,order_type,inbound_code,source_region," +
"source_inbound_id,customer_name,overseas_warehouse_id,overseas_warehouse_name,pallet_count," +
"package_count,product_quantity,current_status,source_event_id,source_loading_plan_id,source_payload_hash," +
"inbound_order_no,created_at,updated_at) VALUES(?,?,?,'ONE_CLICK_DROPSHIP',?,'ACCEPTANCE',?," +
"'废弃验收客户','WH-US-LAX','美西洛杉矶仓',1,?,?, 'WAREHOUSED',?,?,?, ?,NOW(3),NOW(3))",
id, orderNo, CUSTOMER, inboundCode, "ACCEPT-" + id, packages, packages * 10,
"ACCEPT-EVENT-" + id, "ACCEPT-PLAN-" + id, "sha256:acceptance-" + id, orderNo);
}
private void seedProfile(long id, long orderId, String palletNo) {
jdbc.update("INSERT INTO ll_dropship_order_profile(id,customer_id,order_id,warehouse_pallet_id,pallet_no," +
"source_pallet_no,status,idempotency_key,version,created_at,updated_at) " +
"VALUES(?,?,?, ?,?,?,'RECEIVED',?,0,NOW(3),NOW(3))",
id, CUSTOMER, orderId, id, palletNo, "SRC-" + palletNo, "ACCEPT-PROFILE-" + id);
}
private void seedSku(long id, long orderId, long profileId, String sku, String name, int packages) {
jdbc.update("INSERT INTO ll_dropship_sku_inventory(id,customer_id,order_id,profile_id,sku_code,product_name," +
"package_count,product_quantity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,NOW(3),NOW(3))",
id, CUSTOMER, orderId, profileId, sku, name, packages, packages * 10);
}
private void seedCartons(long orderId, long profileId, long firstId, String palletNo,
String sku, String markPrefix, int packages) {
for (int index = 1; index <= packages; index++) {
jdbc.update("INSERT INTO ll_dropship_carton(id,customer_id,order_id,profile_id,warehouse_pallet_id," +
"pallet_no,sku_code,carton_mark,carton_sequence,status,version,created_at,updated_at) " +
"VALUES(?,?,?,?,?,?,?,?,?,'AVAILABLE',0,NOW(3),NOW(3))",
firstId + index - 1, CUSTOMER, orderId, profileId, profileId, palletNo, sku,
"ACCEPT-" + markPrefix + "-" + String.format("%03d", index), index);
}
}
private long skuPackages(long orderId) {
return jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dropship_sku_inventory WHERE customer_id=? AND order_id=?",
Long.class, CUSTOMER, orderId);
}
private int count(String sql) {
return jdbc.queryForObject(sql, Integer.class, CUSTOMER);
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.discard;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CargoDiscardSchemaContractTest {
@Test
void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_application"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_item"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id)"));
}
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.discard.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
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 java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class CargoDiscardControllerTest {
@Mock CargoDiscardService service;
private CargoDiscardController controller;
@BeforeEach
void setUp() {
controller = new CargoDiscardController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void reviewRequiresExplicitApprovalPermission() {
AuthenticatedUser user = user(Set.of());
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.review(
user, null, "FH-1", new CargoDiscardRequests.Review("APPROVE", null)));
assertEquals(403, error.getStatusCode().value());
}
@Test
void merchantTenantComesFromTrustedIdentityWhenApproving() {
AuthenticatedUser user = user(Set.of(CargoDiscardService.APPROVAL_PERMISSION));
CargoDiscardRequests.Review request = new CargoDiscardRequests.Review("APPROVE", "ok");
controller.review(user, "999", "FH-1", request);
verify(service).review("100", "FH-1", "7", request);
}
private AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, 100L, 100L, 8L, "reviewer", "MERCHANT", "WEB", permissions);
}
}
@@ -0,0 +1,108 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
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.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CargoDiscardInventoryAdjustmentServiceTest {
@Mock CargoDiscardInventoryMapper inventory;
@Mock DropshipCartonLedgerMapper ledgers;
private CargoDiscardInventoryAdjustmentService service;
@BeforeEach
void setUp() {
service = new CargoDiscardInventoryAdjustmentService();
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "cartonLedgers", ledgers);
}
@Test
void discardRequiresExistingTransactionAndConservesPackages() throws Exception {
Transactional transactional = CargoDiscardInventoryAdjustmentService.class
.getMethod("discard", String.class, CargoDiscardApplication.class, List.class, Map.class,
String.class, LocalDateTime.class).getAnnotation(Transactional.class);
assertEquals(Propagation.MANDATORY, transactional.propagation());
CargoDiscardApplication application = application(2);
CargoDiscardItem item = item(2);
when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5));
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
when(inventory.skuPackages("100", 301L)).thenReturn(3L);
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
var result = service.discard("100", application, List.of(item),
Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now());
assertEquals(5, result.beforePackages());
assertEquals(2, result.discardedPackages());
assertEquals(3, result.afterPackages());
ArgumentCaptor<DropshipCartonLedger> facts = ArgumentCaptor.forClass(DropshipCartonLedger.class);
verify(ledgers, times(2)).insert(facts.capture());
assertTrue(facts.getAllValues().stream().allMatch(value -> "DISCARD".equals(value.getOperationType())
&& value.getIdempotencyKey().startsWith("CARGO_DISCARD:401:")));
}
@Test
void discardRejectsAnyPackageConservationMismatch() {
CargoDiscardApplication application = application(2);
CargoDiscardItem item = item(2);
when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5));
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
when(inventory.skuPackages("100", 301L)).thenReturn(4L);
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
assertThrows(ResponseStatusException.class, () -> service.discard("100", application, List.of(item),
Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now()));
}
private CargoDiscardApplication application(int packages) {
CargoDiscardApplication value = new CargoDiscardApplication();
value.setId(401L); value.setDiscardNo("FH-1"); value.setTotalPackages(packages); value.setRemark("damaged");
return value;
}
private CargoDiscardItem item(int packages) {
CargoDiscardItem value = new CargoDiscardItem();
value.setId(501L); value.setOrderId(101L); value.setSkuInventoryId(301L); value.setSkuCode("SKU-1");
value.setDiscardPackages(packages); return value;
}
private DropshipSkuInventory sku(long packages) {
DropshipSkuInventory value = new DropshipSkuInventory(); value.setId(301L); value.setPackageCount(packages); return value;
}
private CargoDiscardCarton link(long id) {
CargoDiscardCarton value = new CargoDiscardCarton(); value.setItemId(501L); value.setCartonId(id); return value;
}
private DropshipCarton carton(long id) {
DropshipCarton value = new DropshipCarton(); value.setId(id); value.setCartonMark("C-" + id); return value;
}
}
@@ -0,0 +1,180 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.entity.LastlegOrder;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CargoDiscardServiceTest {
@Mock CargoDiscardApplicationMapper applications;
@Mock CargoDiscardItemMapper items;
@Mock CargoDiscardCartonMapper discardCartons;
@Mock CargoDiscardStatusHistoryMapper histories;
@Mock CargoDiscardInventoryMapper inventory;
@Mock CargoDiscardInventoryAdjustmentService adjustments;
@Mock LastlegOrderMapper orders;
private CargoDiscardService service;
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
private final List<CargoDiscardItem> storedItems = new ArrayList<>();
private final List<CargoDiscardCarton> storedLinks = new ArrayList<>();
private final List<CargoDiscardStatusHistory> storedHistories = new ArrayList<>();
@BeforeEach
void setUp() {
service = new CargoDiscardService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "discardCartons", discardCartons);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "adjustments", adjustments);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00"));
lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; });
lenient().when(items.insert(any(CargoDiscardItem.class))).thenAnswer(invocation -> { storedItems.add(invocation.getArgument(0)); return 1; });
lenient().when(discardCartons.insert(any(CargoDiscardCarton.class))).thenAnswer(invocation -> { storedLinks.add(invocation.getArgument(0)); return 1; });
lenient().when(histories.insert(any(CargoDiscardStatusHistory.class))).thenAnswer(invocation -> { storedHistories.add(invocation.getArgument(0)); return 1; });
lenient().when(applications.findNo(any(), any())).thenAnswer(invocation -> storedApplication.get());
lenient().when(items.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedItems));
lenient().when(discardCartons.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedLinks));
lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories));
}
@Test
void createReservesExactSkuCartonsWithoutDeductingInventory() {
LastlegOrder order = order();
DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, "SKU-1", 2)), "discard-create-1", "damaged"));
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
assertEquals(2, detail.summary().totalPackages());
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
assertEquals(2, storedLinks.size());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
}
@Test
void approvalDeductsSkuInventoryAndWritesOneLedgerPerCarton() {
CargoDiscardApplication application = pendingApplication();
storedApplication.set(application);
CargoDiscardItem item = item();
storedItems.add(item);
storedLinks.add(link(item.getId(), 1L));
storedLinks.add(link(item.getId(), 2L));
when(applications.lockNo("100", "FH-1")).thenReturn(application);
when(adjustments.discard(eq("100"), eq(application), any(), any(), eq("9"), any()))
.thenReturn(new CargoDiscardInventoryAdjustmentService.AdjustmentResult(3, 2, 1));
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
verify(adjustments).discard(eq("100"), eq(application), any(), any(), eq("9"), any());
assertEquals("ok; inventoryBefore=3, discarded=2, inventoryAfter=1",
storedHistories.get(storedHistories.size() - 1).getRemark());
}
@Test
void rejectionReleasesReservedCartonsWithoutDeduction() {
CargoDiscardApplication application = pendingApplication();
storedApplication.set(application);
CargoDiscardItem item = item();
storedItems.add(item);
storedLinks.add(link(item.getId(), 1L));
when(applications.lockNo("100", "FH-1")).thenReturn(application);
when(inventory.changeCartonStatus("100", 1L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1);
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("REJECT", "not damaged"));
assertEquals(CargoDiscardService.REJECTED, detail.summary().status());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
}
private LastlegOrder order() {
LastlegOrder order = new LastlegOrder();
order.setId(101L); order.setCustomerId("100"); order.setOrderType("ONE_CLICK_DROPSHIP");
order.setInboundCode("RK-1"); order.setOverseasWarehouseId("WH-1"); order.setOverseasWarehouseName("LA");
return order;
}
private DropshipSkuInventory skuInventory() {
DropshipSkuInventory value = new DropshipSkuInventory();
value.setId(301L); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L);
value.setSkuCode("SKU-1"); value.setProductName("Box"); value.setPackageCount(3L);
return value;
}
private DropshipCarton carton(Long id) {
DropshipCarton value = new DropshipCarton();
value.setId(id); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L);
value.setSkuCode("SKU-1"); value.setCartonMark("C-" + id); value.setStatus("DISCARD_PENDING");
return value;
}
private CargoDiscardApplication pendingApplication() {
CargoDiscardApplication value = new CargoDiscardApplication();
value.setId(401L); value.setCustomerId("100"); value.setDiscardNo("FH-1"); value.setInboundCodes("RK-1");
value.setOverseasWarehouseId("WH-1"); value.setTotalPackages(2); value.setFeePerPackage(new BigDecimal("5.00"));
value.setTotalFee(new BigDecimal("10.00")); value.setCurrency("USD"); value.setStatus(CargoDiscardService.PENDING_REVIEW);
return value;
}
private CargoDiscardItem item() {
CargoDiscardItem value = new CargoDiscardItem();
value.setId(501L); value.setApplicationId(401L); value.setCustomerId("100"); value.setOrderId(101L);
value.setInboundCode("RK-1"); value.setProfileId(201L); value.setSkuInventoryId(301L);
value.setSkuCode("SKU-1"); value.setAvailablePackagesSnapshot(3); value.setDiscardPackages(2);
return value;
}
private CargoDiscardCarton link(Long itemId, Long cartonId) {
CargoDiscardCarton value = new CargoDiscardCarton();
value.setId(600L + cartonId); value.setApplicationId(401L); value.setItemId(itemId);
value.setCustomerId("100"); value.setCartonId(cartonId); value.setCartonMark("C-" + cartonId);
return value;
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.dispatch;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DispatchSchemaContractTest {
@Test
void baselineDefinesDispatchHeadersItemsAndActivePalletLock() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_item"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock"));
assertTrue(sql.contains("uk_ll_dispatch_active_pallet"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation"));
assertTrue(sql.contains("active_key"));
assertTrue(sql.contains("order_refs"));
}
@Test
void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception {
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java"));
assertTrue(mapper.contains("ll_warehouse_transfer_inventory_allocation"));
assertTrue(mapper.contains("ta.status='RESERVED'"));
assertTrue(mapper.contains("tp.pallet_id=p.warehouse_pallet_id"));
}
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
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.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchPdaWorkflowServiceTest {
@Mock private DispatchOrderMapper orders;
@Mock private DispatchPdaOperationMapper operations;
@Mock private OutboundOrderMapper outboundOrders;
@Test
void pushRequiresVehiclePlateBeforeCreatingPdaTask() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" ");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void pushRequiresWarehouseSelection() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
order.setOverseasWarehouseId("WH-1");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
assertEquals(400, exception.getStatusCode().value());
}
@Test
void departRequiresVehiclePlate() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" ");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departRequiresPendingOutboundOrder() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(null);
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departedRequestReplaysAfterStatusChanged() throws Exception {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
DispatchPdaOperation operation = new DispatchPdaOperation();
operation.setOperationType("DEPART"); operation.setStatus("COMPLETED");
operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false)));
when(operations.findKey("100", "depart-1")).thenReturn(operation);
// This exact key/hash pair models the persisted first execution result.
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request);
assertEquals("DEPARTED", result.status());
assertEquals(true, result.replayed());
}
}
@@ -0,0 +1,102 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import org.apache.ibatis.annotations.Update;
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.LocalDateTime;
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DispatchPickOrderAttributionServiceTest {
@Mock private DispatchPdaPickOrderMapper attributions;
@Mock private LastlegOrderMapper orders;
@Mock private LastlegOrderStatusHistoryMapper history;
@Test
void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException {
Update update = LastlegOrderMapper.class.getMethod("transferPickedToShipped",
String.class, Long.class, long.class, String.class).getAnnotation(Update.class);
String sql = String.join("", Arrays.asList(update.value())).replaceAll("\\s+", " ");
assertTrue(sql.indexOf("current_status=CASE")
< sql.indexOf("shipped_package_count=shipped_package_count+"));
}
@Test
void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() {
DispatchPickOrderAttributionService service = service();
InboundStockReceipt first = receipt(11L, 101L, 3L, 30L);
InboundStockReceipt second = receipt(12L, 102L, 4L, 40L);
when(attributions.availableReceipts("t1", 91L)).thenReturn(List.of(first, second));
when(attributions.attributedPackages(eq("t1"), anyLong())).thenReturn(0L);
when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1);
DispatchPdaPick pick = pick(81L, 91L, 5L, 50L);
List<DispatchPickOrderAttributionService.OrderPackages> result = service.attribute("t1", pick);
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L),
new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result);
}
@Test
void departureMovesOnlyAttributedPackagesAndWritesOrderHistory() {
DispatchPickOrderAttributionService service = service();
DispatchPdaPick pick = pick(81L, 91L, 4L, 40L);
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setOrderId(101L); row.setPackages(4L);
when(attributions.listPick("t1", 81L)).thenReturn(List.of(row));
LastlegOrder order = new LastlegOrder();
order.setId(101L); order.setPackageCount(10); order.setPickedPackageCount(4);
order.setShippedPackageCount(2); order.setCurrentStatus("INBOUND_COMPLETED");
when(orders.lockById("t1", 101L)).thenReturn(order);
when(orders.transferPickedToShipped("t1", 101L, 4L, "OUT1")).thenReturn(1);
List<DispatchPickOrderAttributionService.OrderPackages> result = service.depart(
"t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0));
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 4L)), result);
verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "PARTIALLY_SHIPPED".equals(value.getToStatus())
&& value.getRemark().contains("6/10")));
}
private DispatchPickOrderAttributionService service() {
DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService();
ReflectionTestUtils.setField(service, "attributions", attributions);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "history", history);
return service;
}
private static InboundStockReceipt receipt(long id, long orderId, long packages, long quantity) {
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(id); receipt.setOrderId(orderId); receipt.setReceivedPackages(packages);
return receipt;
}
private static DispatchPdaPick pick(long id, long positionId, long packages, long quantity) {
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(id); pick.setInventoryPositionId(positionId); pick.setPackages(packages);
pick.setProductQuantity(quantity); pick.setPickedAt(LocalDateTime.of(2026, 8, 17, 14, 0));
return pick;
}
}
@@ -0,0 +1,112 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import 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.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
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.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchServiceTest {
@Mock DispatchOrderMapper orders;
@Mock DispatchItemMapper items;
@Mock DispatchPalletLockMapper locks;
@Mock DispatchCandidateMapper candidates;
private DispatchService service;
@BeforeEach
void setUp() {
service = new DispatchService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "locks", locks);
ReflectionTestUtils.setField(service, "candidates", candidates);
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
}
@Test
void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception {
assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class,
DispatchRequests.PreArrange.class).getAnnotation(Transactional.class));
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
null, null, null));
assertEquals(DispatchService.PENDING_DEPARTURE, result.status());
assertEquals("ONT8", result.route());
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
verify(orders).insert(dispatch.capture());
assertEquals(1, dispatch.getValue().getPalletCount());
verify(locks).insert(any(DispatchPalletLock.class));
verify(items).insert(any(DispatchItem.class));
}
@Test
void rejectsPalletAlreadyLockedByAnotherDispatch() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
DispatchPalletLock locked = new DispatchPalletLock();
when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked);
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void invalidCandidateStageIsRejected() {
assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID"));
verify(candidates, never()).available(any(), any(), any(), any());
}
@Test
void editCannotBypassPdaManagedDispatchStatus() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch);
when(orders.lockById("100", 1L)).thenReturn(dispatch);
DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null,
LocalDateTime.now(), null, null, null, null, "COMPLETED");
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.update("100", "PC1", request));
assertEquals(400, exception.getStatusCode().value());
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
verify(orders, never()).updateById(any(DispatchOrder.class));
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1",
"LIP1-P001", destination, destination, destination, destination, 12,
new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED");
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dropship;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class DropshipSchemaContractTest {
@Test
void baselineAndIncrementalSqlKeepContainerAndMultiPalletIndexes() 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();
assertThat(baseline).contains(
"key idx_ll_order_container(customer_id,container_no)",
"unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
"unique key uk_ll_dropship_sku_inventory(profile_id,sku_code)",
"key idx_ll_dropship_profile_order(customer_id,order_id)",
"warehouse_pallet_id bigint null comment '仓库实物托盘id'",
"pallet_no varchar(128) null comment '托盘号'",
"status varchar(32) not null default 'pending_inbound'");
assertThat(migration).contains(
"add key idx_ll_order_container (customer_id,container_no)",
"add unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
"add unique key uk_ll_dropship_sku_profile(profile_id,sku_code)",
"add key idx_ll_dropship_profile_order(customer_id,order_id)",
"modify column warehouse_pallet_id bigint null",
"modify column pallet_no varchar(128) null");
}
}
@@ -0,0 +1,148 @@
package com.baiyue.lastleg.dropship.service;
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DropshipOrderServiceTest {
@Mock private LastlegOrderMapper orders;
@Mock private DropshipOrderProfileMapper profiles;
@Mock private DropshipSkuInventoryMapper skuInventory;
@Mock private DropshipCartonMapper cartons;
@Mock private DropshipCartonLedgerMapper ledger;
@Mock private WarehouseResourceGateway warehouse;
@InjectMocks private DropshipOrderService service;
@Test
void receivesMultiplePalletsForOneInboundOrder() {
LastlegOrder order = new LastlegOrder();
order.setId(10L);
order.setCustomerId("C-1");
order.setOrderType("ONE_CLICK_DROPSHIP");
order.setOrderNo("LL-10");
order.setPackageCount(3);
order.setProductQuantity(30);
when(orders.selectById(10L)).thenReturn(order);
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of());
DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-1",
List.of(
new DropshipRequests.Pallet("SOURCE-P-1", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20,
List.of("MARK-1", "MARK-2")))),
new DropshipRequests.Pallet("SOURCE-P-2", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10,
List.of("MARK-3"))))));
assertThat(service.receive(request)).isEqualTo(3);
verifyNoInteractions(warehouse);
verify(profiles, times(2)).insert(any(DropshipOrderProfile.class));
verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class));
verify(cartons, times(3)).insert(any(DropshipCarton.class));
verify(ledger, times(3)).insert(any(DropshipCartonLedger.class));
ArgumentCaptor<DropshipOrderProfile> profilesCaptor = ArgumentCaptor.forClass(DropshipOrderProfile.class);
verify(profiles, times(2)).insert(profilesCaptor.capture());
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getSourcePalletNo)
.containsExactly("SOURCE-P-1", "SOURCE-P-2");
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getCustomerId)
.containsOnly("C-1");
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getStatus)
.containsOnly("PENDING_INBOUND");
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getWarehousePalletId)
.containsOnlyNulls();
ArgumentCaptor<DropshipCarton> cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class);
verify(cartons, times(3)).insert(cartonCaptor.capture());
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus)
.containsOnly("PENDING_INBOUND");
ArgumentCaptor<DropshipCartonLedger> ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class);
verify(ledger, times(3)).insert(ledgerCaptor.capture());
assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getOperationType)
.containsOnly("SNAPSHOT_CREATED");
assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages)
.containsOnly(0L);
}
@Test
void activatesMultiplePalletsOnlyAfterPdaStockIn() {
DropshipOrderProfile first = profile(1L, "SOURCE-P-1");
DropshipOrderProfile second = profile(2L, "SOURCE-P-2");
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(first, second));
DropshipCarton firstCarton = carton(11L, 1L, "MARK-1");
DropshipCarton secondCarton = carton(12L, 2L, "MARK-2");
when(cartons.listProfile("C-1", 1L)).thenReturn(List.of(firstCarton));
when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton));
service.activateAfterStockIn("C-1", 10L, 50L, List.of(
receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1)));
assertThat(first.getStatus()).isEqualTo("ACTIVE");
assertThat(first.getWarehousePalletId()).isEqualTo(101L);
assertThat(firstCarton.getStatus()).isEqualTo("AVAILABLE");
assertThat(firstCarton.getWarehousePalletId()).isEqualTo(101L);
verify(profiles, times(2)).updateById(any(DropshipOrderProfile.class));
verify(cartons, times(2)).updateById(any(DropshipCarton.class));
ArgumentCaptor<DropshipCartonLedger> facts = ArgumentCaptor.forClass(DropshipCartonLedger.class);
verify(ledger, times(2)).insert(facts.capture());
assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getOperationType).containsOnly("STOCK_IN");
assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages).containsOnly(1L);
}
@Test
void repeatedPdaActivationIsANoOp() {
DropshipOrderProfile active = profile(1L, "SOURCE-P-1");
active.setStatus("ACTIVE");
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(active));
service.activateAfterStockIn("C-1", 10L, 50L,
List.of(receipt(101L, "WH-P-1", 201L, 1)));
verify(profiles, never()).updateById(any(DropshipOrderProfile.class));
verify(cartons, never()).updateById(any(DropshipCarton.class));
verify(ledger, never()).insert(any(DropshipCartonLedger.class));
}
private static DropshipOrderProfile profile(long id, String sourcePalletNo) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(id); profile.setCustomerId("C-1"); profile.setOrderId(10L);
profile.setSourcePalletNo(sourcePalletNo); profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
return profile;
}
private static DropshipCarton carton(long id, long profileId, String mark) {
DropshipCarton carton = new DropshipCarton();
carton.setId(id); carton.setCustomerId("C-1"); carton.setOrderId(10L); carton.setProfileId(profileId);
carton.setCartonMark(mark); carton.setSkuCode("SKU-A"); carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L);
return carton;
}
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.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages);
return receipt;
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.inboundplan;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanSchemaContractTest {
@Test
void planIsUniqueByTenantAndActualContainerNumber() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertTrue(stream != null, "lastleg schema must be available");
String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (customer_id, container_no)"));
assertTrue(schema.contains("default 'pending_assignment'"));
assertTrue(schema.contains("required_package_count"));
assertTrue(schema.contains("planned_package_count"));
assertTrue(schema.contains("warehouse_resource_lock_id"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_task"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
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("create table if not exists ll_inbound_fba_appointment_item"));
assertTrue(schema.contains("fba_warehouse_code"));
assertFalse(schema.contains("cabinet_no"));
}
}
}
@@ -0,0 +1,374 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
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.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.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.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPdaWorkflowServiceTest {
@Mock PdaWorkflowRepository repository;
@Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts;
private InboundPdaWorkflowService service;
private InboundPlan plan;
private InboundPdaTask task;
private InboundPlanLine targetLine;
private InboundPlanLine sourceLine;
private InboundPlanPallet targetPallet;
private InboundPlanPallet sourcePallet;
private InboundPlanPallet otherWarehousePallet;
private InboundPlanPalletAllocation target;
private InboundPlanPalletAllocation source;
private InboundPlanPalletAllocation otherWarehouse;
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
plan = plan();
task = task();
targetLine = line(11L, 101L, "RK-A", "1.0000", 5);
sourceLine = line(12L, 102L, "RK-B", "0.5000", 2);
targetPallet = pallet(21L, "WH-A", 3, "0.6000", 901L);
sourcePallet = pallet(22L, "WH-A", 2, "0.5000", 902L);
otherWarehousePallet = pallet(23L, "WH-B", 4, "0.8000", 903L);
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
}
@Test
void pushRequiresEveryPalletToBeAssignedAndLocked() {
targetPallet.setWarehouseResourceLockId(null);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
assertThrows(ResponseStatusException.class,
() -> service.push("100", "LIP1", new PdaRequests.Push("push-1", "7", "worker")));
verify(repository, never()).insert(any(InboundPdaTask.class));
}
@Test
void assignmentReservesWarehouseResourcesAndPersistsTheLock() {
targetPallet.setDestinationWarehouseId("88");
targetPallet.setWarehousePalletId(null);
targetPallet.setWarehouseLocationId(null);
targetPallet.setWarehouseResourceLockId(null);
targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT");
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1"))
.thenReturn(999L);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, 188L, 288L, "assign-1"));
assertEquals(188L, targetPallet.getWarehousePalletId());
assertEquals(288L, targetPallet.getWarehouseLocationId());
assertEquals(999L, targetPallet.getWarehouseResourceLockId());
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
assertEquals("ASSIGNED", plan.getStatus());
verify(repository).update(targetPallet);
}
@Test
void repeatedAssignmentIsIdempotentAndWrongDestinationIsRejected() {
targetPallet.setDestinationWarehouseId("88");
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, targetPallet.getWarehousePalletId(),
targetPallet.getWarehouseLocationId(), "assign-retry"));
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(),
any(), any(), any());
assertThrows(ResponseStatusException.class, () -> service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(99L, targetPallet.getWarehousePalletId(),
targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse")));
}
@Test
void countMustPrecedePickAndPickedBoxesDriveActualVolume() {
activeTask();
target.setCountedPackageCount(0);
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(1, "pick-before-count", "7", "worker")));
service.count("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(3, "count-3", "7", "worker"));
var result = service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(2, "pick-2", "7", "worker"));
assertEquals("PICKED", result.result());
assertEquals(2, target.getPickedPackageCount());
assertEquals("0.4000", target.getActualVolumeCbm().toPlainString());
assertEquals(5, targetPallet.getActualPackageCount());
assertEquals("1.0000", targetPallet.getActualVolumeCbm().toPlainString());
}
@Test
void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
candidateQueries();
var result = service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(5, "pick-over", "7", "worker"));
assertEquals("MERGE_CONFIRMATION_REQUIRED", result.result());
assertEquals(3, result.remainingPackageCount());
assertEquals(2, result.mergePackageCount());
assertEquals(1, result.candidates().size());
assertEquals(source.getId(), result.candidates().get(0).allocationId());
assertEquals(0, target.getPickedPackageCount());
verify(repository, never()).update(target);
verify(repository, never()).update(targetPallet);
verify(repository).insert(any(InboundPdaOperation.class));
}
@Test
void mergeCandidatesOnlyIncludeSameDestinationPallets() {
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
candidateQueries();
var result = service.mergeCandidates("100", "LIP1", target.getId());
assertEquals(1, result.size());
assertEquals("RK-B", result.get(0).inboundCode());
assertEquals("0.5000", result.get(0).availableVolumeCbm().toPlainString());
}
@Test
void confirmedMergePersistsBothOrdersAndScrapsAnEmptiedSourcePallet() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet);
when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.allocations(plan.getId())).thenReturn(List.of(target, source));
var result = service.merge("100", "LIP1", target.getId(),
new PdaRequests.ConfirmMerge(source.getId(), 2, "merge-1", "7", "worker"));
assertEquals("MERGED", result.result());
assertEquals(sourceLine.getOrderId(), result.sourceOrderId());
assertEquals(targetLine.getOrderId(), result.targetOrderId());
assertTrue(result.sourcePalletScrapped());
assertEquals(0, sourcePallet.getActualPackageCount());
assertEquals(5, targetPallet.getActualPackageCount());
verify(warehouse).scrap("100", 902L);
ArgumentCaptor<InboundPalletMerge> merge = ArgumentCaptor.forClass(InboundPalletMerge.class);
verify(repository).insert(merge.capture());
assertEquals(sourceLine.getOrderId(), merge.getValue().getSourceOrderId());
assertEquals(targetLine.getOrderId(), merge.getValue().getTargetOrderId());
assertTrue(merge.getValue().getSourcePalletScrapped());
}
@Test
void mergeCannotExceedTargetPalletVolumeCapacity() {
activeTask();
targetPallet.setActualVolumeCbm(new BigDecimal("1.9000"));
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet);
when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
assertThrows(ResponseStatusException.class, () -> service.merge("100", "LIP1", target.getId(),
new PdaRequests.ConfirmMerge(source.getId(), 1, "merge-over-capacity", "7", "worker")));
verify(warehouse, never()).scrap(any(), anyLong());
verify(repository, never()).insert(any(InboundPalletMerge.class));
}
@Test
void completionRequiresExactPickedTotalForEveryLine() {
activeTask();
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1"));
assertFalse("COMPLETED".equals(task.getStatus()));
verify(repository, never()).update(plan);
}
@Test
void completesWhenMergedAllocationsPreserveExactLineTotals() {
activeTask();
target.setPickedPackageCount(3);
InboundPlanPalletAllocation moved = allocation(34L, targetLine.getId(), sourcePallet.getId(), 0, 2, 2);
when(repository.allocations(plan.getId())).thenReturn(List.of(target, moved));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
var result = service.complete("100", "LIP1");
assertEquals("COMPLETED", result.status());
assertEquals("COMPLETED", plan.getStatus());
verify(repository).update(task);
verify(repository).update(plan);
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target, moved)), any(), any());
}
@Test
void completedTaskReplayDoesNotRunStockInAgain() {
task.setStatus("COMPLETED");
activeTask();
var result = service.complete("100", "LIP1");
assertEquals("COMPLETED", result.status());
verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any());
verify(repository, never()).allocations(plan.getId());
}
@Test
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
InboundPdaOperation existing = new InboundPdaOperation();
existing.setAction("COUNT");
existing.setRequestHash("different");
when(repository.operation("100", "same-key")).thenReturn(existing);
assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(1, "same-key", "7", "worker")));
}
@Test
void completedTaskStillReplaysAnAlreadyPersistedPick() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
PdaRequests.PackageOperation request = new PdaRequests.PackageOperation(1, "pick-replay", "7", "worker");
var first = service.pick("100", "LIP1", target.getId(), request);
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
verify(repository).insert(operation.capture());
task.setStatus("COMPLETED");
when(repository.operation("100", "pick-replay")).thenReturn(operation.getValue());
var replay = service.pick("100", "LIP1", target.getId(), request);
assertEquals(first, replay);
assertEquals(1, target.getPickedPackageCount());
verify(repository, times(1)).lockAllocation(plan.getId(), target.getId());
}
private void activeTask() { when(repository.lockTask("100", plan.getId())).thenReturn(task); }
private void candidateQueries() {
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet, sourcePallet, otherWarehousePallet));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine, sourceLine));
when(repository.allocations(plan.getId())).thenReturn(List.of(target, source, otherWarehouse));
}
private InboundPlan plan() {
InboundPlan value = new InboundPlan();
value.setId(1L);
value.setCustomerId("100");
value.setPlanNo("LIP1");
value.setStatus("ASSIGNED");
return value;
}
private InboundPdaTask task() {
InboundPdaTask value = new InboundPdaTask();
value.setId(2L);
value.setPlanId(1L);
value.setCustomerId("100");
value.setTaskNo("PDA2");
value.setStatus("PDA_PENDING");
value.setVersion(0);
return value;
}
private InboundPlanLine line(long id, long orderId, String inboundCode, String volume, int packages) {
InboundPlanLine value = new InboundPlanLine();
value.setId(id);
value.setPlanId(1L);
value.setOrderId(orderId);
value.setInboundCode(inboundCode);
value.setDestinationWarehouseId("WH-A");
value.setVolumeCbm(new BigDecimal(volume));
value.setRequiredPackageCount(packages);
return value;
}
private InboundPlanPallet pallet(long id, String warehouseId, int actualPackages,
String actualVolume, long lockId) {
InboundPlanPallet value = new InboundPlanPallet();
value.setId(id);
value.setPlanId(1L);
value.setPalletNo("PLT-" + id);
value.setDestinationWarehouseId(warehouseId);
value.setCapacityCbm(new BigDecimal("2.0000"));
value.setWarehousePalletId(id + 1000);
value.setWarehouseLocationId(id + 2000);
value.setWarehouseResourceLockId(lockId);
value.setAssignmentStatus("ASSIGNED");
value.setActualPackageCount(actualPackages);
value.setActualVolumeCbm(new BigDecimal(actualVolume));
return value;
}
private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId,
int planned, int counted, int picked) {
InboundPlanPalletAllocation value = new InboundPlanPalletAllocation();
value.setId(id);
value.setPlanId(1L);
value.setLineId(lineId);
value.setPalletId(palletId);
value.setPlannedPackageCount(planned);
value.setCountedPackageCount(counted);
value.setPickedPackageCount(picked);
value.setActualVolumeCbm(BigDecimal.ZERO);
value.setVersion(0);
return value;
}
}
@@ -0,0 +1,214 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
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.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
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.math.BigDecimal;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
private InboundStockReceiptService service;
@BeforeEach
void setUp() {
service = new InboundStockReceiptService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
}
@Test
void writesOneReceiptPerSkuAndPalletAndUsesPdaPackageCount() {
Fixture fixture = fixture(5, 5, 5, 10);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
assertEquals(5L, request.getValue().packages());
assertEquals(101L, request.getValue().inboundOrderId());
assertEquals("IN-1", request.getValue().inboundCode());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
assertEquals(10L, receipt.getValue().getInventoryPositionId());
assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus());
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() {
Fixture fixture = fixture(10, 5, 5, 10);
fixture.detail.setPackageCount(5);
LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task,
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
List.of(fixture.pallet, secondPallet), LocalDateTime.now());
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any());
verify(receipts, times(2)).insert(any(InboundStockReceipt.class));
}
@Test
void conservesSingleSkuPackagesAndQuantityAcrossPallets() {
Fixture fixture = fixture(5, 2, 2, 11);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 3);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum());
}
@Test
void existingTaskReceiptMakesStockInReplayANoOp() {
Fixture fixture = fixture(10, 5, 5, 10);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
InboundStockReceipt firstReceipt = new InboundStockReceipt();
firstReceipt.setAllocationId(fixture.allocation.getId()); firstReceipt.setOrderId(fixture.order.getId());
InboundStockReceipt secondReceipt = new InboundStockReceipt();
secondReceipt.setAllocationId(secondAllocation.getId()); secondReceipt.setOrderId(fixture.order.getId());
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(firstReceipt, secondReceipt));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
}
@Test
void partialTaskReceiptsAreRejected() {
Fixture fixture = fixture(10, 5, 5, 10);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
InboundStockReceipt partial = new InboundStockReceipt();
partial.setAllocationId(fixture.allocation.getId()); partial.setOrderId(fixture.order.getId());
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(partial));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.stockIn("100", fixture.plan, fixture.task,
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
List.of(fixture.pallet, secondPallet), LocalDateTime.now()));
assertEquals(409, exception.getStatusCode().value());
verify(inventory, never()).stockIn(any(), any(), any());
verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any());
}
@Test
void activatesDropshipAfterGenericStockInReceiptIsCreated() {
Fixture fixture = fixture(5, 5, 5, 10);
fixture.order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
ArgumentCaptor<List<InboundStockReceipt>> activationReceipts = ArgumentCaptor.forClass(List.class);
verify(dropshipOrders).activateAfterStockIn(eq("100"), eq(101L), eq(2L), activationReceipts.capture());
assertEquals(1, activationReceipts.getValue().size());
assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId());
}
private Fixture fixture(int required, int planned, int picked, int quantity) {
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1");
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L);
line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST");
line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8");
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.setOverseasWarehouseId("OW-1");
order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity);
return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L));
}
private LastlegOrderDetail detail(long id, String sku, int packages, int quantity) {
LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setId(id); detail.setOrderId(101L);
detail.setSkuCode(sku); detail.setPackageCount(packages); detail.setProductQuantity(quantity);
detail.setProductNameZh(sku + " name"); return detail;
}
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.setWarehouseLocationId(3001L); return pallet;
}
private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, int picked) {
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(id);
allocation.setPlanId(1L); allocation.setLineId(lineId); allocation.setPalletId(palletId);
allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation;
}
private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order,
LastlegOrderDetail detail, InboundPlanPalletAllocation allocation,
InboundPlanPallet pallet) {}
}
@@ -0,0 +1,137 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
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.transaction.annotation.Transactional;
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundFbaAppointmentServiceTest {
@Mock InboundFbaAppointmentMapper appointmentMapper;
@Mock InboundFbaAppointmentItemMapper itemMapper;
@Mock InboundFbaAppointmentQueryMapper queryMapper;
private InboundFbaAppointmentService service;
private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30);
@BeforeEach
void setUp() {
service = new InboundFbaAppointmentService();
ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper);
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
}
@Test
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
assertEquals(true, InboundFbaAppointmentService.class
.getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class)
.getAnnotation(Transactional.class) != null);
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"));
assertEquals("APT-1", result.appointmentNo());
assertEquals("ONT8", result.fbaWarehouseCode());
assertEquals(2, result.selectedCount());
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
}
@Test
void rejectsMixedFbaWarehouses() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void rejectsMissingLineFromAnotherTenant() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
}
@Test
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1")));
verify(queryMapper, never()).lockLines(any(), any());
}
@Test
void sameAppointmentReplayReturnsExistingResult() {
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
item.setInboundCode("IN-11");
item.setFbaWarehouseCode("ONT8");
when(appointmentMapper.selectOne(any())).thenReturn(existing);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1"));
assertEquals(99L, result.id());
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void sameAppointmentNumberWithDifferentSelectionConflicts() {
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
}
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, "SUCCESS", "dock");
}
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba);
}
private InboundFbaAppointment appointment(Long id, String number, String fba) {
InboundFbaAppointment value = new InboundFbaAppointment();
value.setId(id);
value.setCustomerId("100");
value.setAppointmentNo(number);
value.setAppointmentAt(appointmentAt);
value.setAppointmentStatus("SUCCESS");
value.setAppointmentRemark("dock");
value.setFbaWarehouseCode(fba);
value.setSelectedCount(1);
return value;
}
}
@@ -0,0 +1,143 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
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.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
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.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPlanServiceTest {
@Mock InboundPlanMapper planMapper;
@Mock InboundPlanLineMapper lineMapper;
@Mock InboundPlanPalletMapper palletMapper;
@Mock InboundPlanPalletAllocationMapper allocationMapper;
@Mock InboundPlanOrderMapper orderMapper;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service, "planMapper", planMapper);
ReflectionTestUtils.setField(service, "lineMapper", lineMapper);
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper);
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
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());
}
@Test
void convertsWholeContainerInOneTransaction() throws Exception {
assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class)
.getAnnotation(Transactional.class));
when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(
order(1L, "RK-1", "1.5000", "WH-A"),
order(2L, "RK-2", "1.0000", "WH-A")));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convert("100", 2L);
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture());
assertEquals(2, plan.getValue().getLineCount());
assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus());
assertEquals(2, plan.getValue().getPalletCount());
assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm());
verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class));
ArgumentCaptor<InboundPlanLine> lines = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper, times(2)).insert(lines.capture());
assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum());
ArgumentCaptor<InboundPlanPalletAllocation> allocations =
ArgumentCaptor.forClass(InboundPlanPalletAllocation.class);
verify(allocationMapper, times(3)).insert(allocations.capture());
assertEquals(25, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum());
assertEquals(0, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum());
assertEquals(3, allocations.getAllValues().stream()
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any());
verify(orderMapper).lockContainerOrders("100", "CONT-1");
assertEquals("CONT-1", result.containerNo());
}
@Test
void repeatedConversionReturnsExistingPlanWithoutWriting() {
InboundPlan existing = new InboundPlan();
existing.setId(9L);
existing.setPlanNo("LIP9");
existing.setCustomerId("100");
existing.setContainerNo("CONT-1");
existing.setStatus("PLANNED");
existing.setLineCount(1);
existing.setPalletCount(1);
existing.setTotalVolumeCbm(BigDecimal.ONE);
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing);
assertEquals("LIP9", service.convert("100", 1L).planNo());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
}
@Test
void rejectsMissingDestinationWarehouse() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
}
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
warehouse, warehouse, warehouse, 1, 0);
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.inboundplan.service;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PalletAllocatorTest {
@Test
void roundsEachDestinationGroupUpToTwoCbmPallets() {
assertEquals(2, allocate("4.0000").pallets().size());
assertEquals(3, allocate("4.0100").pallets().size());
assertEquals(1, allocate("0.1000").pallets().size());
assertEquals(0, allocate("0.0000").pallets().size());
}
@Test
void neverMixesDestinationWarehousesAndCanSplitOneLine() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("2.5000"), 5),
new PalletAllocator.LineInput(2L, "WH-B", new BigDecimal("0.1000"), 1)));
assertEquals(3, result.pallets().size());
assertEquals(2, result.allocations().stream().filter(item -> item.orderId() == 1L).count());
assertTrue(result.allocations().stream().allMatch(item ->
item.pallet().warehouseKey().equals(item.orderId() == 1L ? "WH-A" : "WH-B")));
}
@Test
void distributesPackagesProportionallyAndAssignsRemainderToLastPallet() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 10)));
assertEquals(List.of(4, 4, 2), result.allocations().stream()
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
assertEquals(10, result.allocations().stream()
.mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum());
}
@Test
void doesNotLosePackagesWhenEarlyProportionalSharesRoundDown() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 1)));
assertEquals(List.of(0, 0, 1), result.allocations().stream()
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
}
private PalletAllocator.Result allocate(String volume) {
return PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume),
new BigDecimal(volume).signum() == 0 ? 0 : 1)));
}
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.inventory;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InventorySchemaContractTest {
@Test
void baselineDefinesInventoryDimensionsStatesAndIdempotency() throws IOException {
String schema;
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertNotNull(stream, "lastleg schema must be available");
schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
}
assertTrue(schema.contains("create table if not exists ll_inventory_balance"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)"));
assertTrue(schema.contains("inbound_order_id bigint not null"));
assertTrue(schema.contains("inbound_code varchar(128) not null"));
assertTrue(schema.contains("available_packages bigint not null default 0"));
assertTrue(schema.contains("in_transit_packages bigint not null default 0"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity"));
assertTrue(schema.contains("create table if not exists ll_inventory_position"));
assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)"));
assertTrue(schema.contains("warehouse_location_code varchar(128) not null"));
assertFalse(tableDefinition(schema, "ll_inventory_position").contains("sku_code"));
assertFalse(tableDefinition(schema, "ll_inventory_position").contains("product_quantity"));
assertTrue(schema.contains("create table if not exists ll_inventory_ledger"));
assertTrue(schema.contains("business_order_id bigint null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
assertTrue(schema.contains("before_packages bigint not null"));
assertTrue(schema.contains("delta_packages bigint not null"));
assertTrue(schema.contains("after_packages bigint not null"));
assertTrue(tableDefinition(schema, "ll_inventory_ledger").contains("sku_code"));
assertTrue(tableDefinition(schema, "ll_inventory_ledger").contains("carton_mark"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("before_quantity"));
assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_inventory_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id)"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at"));
}
@Test
void inventoryEntitiesUseSnowflakeIds() throws NoSuchFieldException {
for (Class<?> type : new Class<?>[] {
InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class
}) {
TableId tableId = type.getDeclaredField("id").getAnnotation(TableId.class);
assertNotNull(tableId, type.getSimpleName());
assertEquals(IdType.ASSIGN_ID, tableId.type(), type.getSimpleName());
}
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(migration.contains("2026-08-17 / 008"));
assertTrue(migration.contains("create table if not exists ll_inventory_balance"));
assertTrue(migration.contains("create table if not exists ll_inventory_position"));
assertTrue(migration.contains("create table if not exists ll_inventory_ledger"));
assertTrue(migration.contains("create table if not exists ll_inventory_operation"));
assertTrue(migration.contains("2026-08-17 / 015"));
assertTrue(migration.contains("add column inbound_order_id bigint null"));
assertTrue(migration.contains("quantity columns remain untouched"));
assertTrue(migration.contains("2026-08-17 / 018"));
assertTrue(migration.contains("alter table ll_order add column picked_package_count"));
assertTrue(migration.contains("alter table ll_order add column shipped_package_count"));
assertTrue(migration.contains("alter table ll_inventory_balance add column inbound_order_no"));
assertTrue(migration.contains("update ll_inventory_balance"));
assertTrue(migration.contains("where inbound_order_id is null or inbound_code is null or inbound_code = ''"));
assertTrue(migration.contains("alter table ll_inventory_ledger add column inbound_order_id"));
assertTrue(migration.contains("update ll_inventory_ledger"));
assertTrue(migration.contains("alter table ll_dispatch_order add column partial_completion_reason"));
assertFalse(migration.contains("drop column sku_code"));
assertFalse(migration.contains("drop column product_quantity"));
}
@Test
void entitiesExposeInboundOrderPackageDimensionAndLegacyQuantityPlaceholders() throws NoSuchFieldException {
assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId"));
assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId"));
assertThrowsNoField(InventoryBalance.class, "skuCode");
assertThrowsNoField(InventoryBalance.class, "availableQuantity");
assertThrowsNoField(InventoryPosition.class, "skuCode");
assertThrowsNoField(InventoryPosition.class, "productQuantity");
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity"));
}
private static void assertThrowsNoField(Class<?> type, String fieldName) {
assertTrue(java.util.Arrays.stream(type.getDeclaredFields()).noneMatch(field -> field.getName().equals(fieldName)),
() -> type.getSimpleName() + " must not expose " + fieldName);
}
private static String tableDefinition(String schema, String tableName) {
int start = schema.indexOf("create table if not exists " + tableName);
int end = schema.indexOf("engine=innodb", start);
return schema.substring(start, end);
}
}
@@ -0,0 +1,165 @@
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.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
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.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InventoryCommandServiceTest {
@Mock InventoryBalanceMapper balances;
@Mock InventoryPositionMapper positions;
@Mock InventoryLedgerMapper ledgers;
@Mock InventoryOperationMapper operations;
@Mock InventoryCommandMapper commands;
@Mock WarehouseResourceGateway warehouseGateway;
private InventoryCommandService service;
@BeforeEach
void setUp() {
service = new InventoryCommandService();
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "ledgers", ledgers);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
lenient().when(operations.findIdempotency(any(), any())).thenReturn(null);
}
@Test
void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class,
InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24));
assertEquals(12, result.availablePackages());
assertFalse(result.replayed());
ArgumentCaptor<InventoryBalance> balance = ArgumentCaptor.forClass(InventoryBalance.class);
verify(balances).updateById(balance.capture());
assertEquals(12L, balance.getValue().getAvailablePackages());
verify(positions).insert(any(InventoryPosition.class));
verify(ledgers).insert(any(InventoryLedger.class));
verify(operations).updateById(any(InventoryOperation.class));
}
@Test
void stockInAllowsPalletPositionWithZeroOptionalPackages() {
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0));
assertEquals(0, result.availablePackages());
verify(positions).insert(any(InventoryPosition.class));
verify(ledgers).insert(any(InventoryLedger.class));
}
@Test
void holdRejectsWhenAvailablePackagesAreInsufficient() {
InventoryBalance balance = balance(200L, 5);
InventoryPosition position = position(201L, 200L, 5);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
assertThrows(ResponseStatusException.class,
() -> service.hold("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 6, "ORD-1", null, null, "hold-1", null)));
verify(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
service.stockIn("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
when(operations.findIdempotency("100", "stock-in-1")).thenReturn(operation.getValue());
InventoryCommandResponse replay = service.stockIn("100", "7", request);
assertTrue(replay.replayed());
verify(balances, times(1)).updateById(any(InventoryBalance.class));
verify(ledgers, times(1)).insert(any(InventoryLedger.class));
}
@Test
void shippingLastPackagesScrapsPalletWithoutReleasingItsVirtualLocation() {
InventoryBalance balance = balance(200L, 0);
balance.setPickedPackages(5L);
InventoryPosition position = position(201L, 200L, 0);
position.setPickedPackages(5L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.palletPackages("100", 300L)).thenReturn(0L);
var result = service.ship("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null));
assertTrue(result.palletEmpty());
assertEquals("EMPTY", position.getStatus());
verify(warehouseGateway).scrapPallet("100", 300L);
verify(ledgers).insert(any(InventoryLedger.class));
}
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", 300L, "PLT-1", null, null, null,
packages, "ORD-1", "EVT-1", key, null);
}
private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setOverseasWarehouseId("WH-1");
value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L);
value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1");
value.setAvailablePackages(available);
value.setHoldPackages(0L); value.setAllocatedPackages(0L); value.setPickedPackages(0L);
value.setVersion(0L);
return value;
}
private InventoryPosition position(Long id, Long balanceId, long available) {
InventoryPosition value = new InventoryPosition();
value.setId(id); value.setCustomerId("100"); value.setBalanceId(balanceId);
value.setPalletId(300L); value.setPalletNo("PLT-1"); value.setBatchNo("DEFAULT");
value.setAvailablePackages(available); value.setHoldPackages(0L);
value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L);
value.setVersion(0L);
return value;
}
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.inventory.command.controller;
import com.baiyue.common.security.AuthenticatedUser;
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 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 java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InventoryCommandControllerTest {
@Mock InventoryCommandService service;
private InventoryCommandController controller;
@BeforeEach
void setUp() {
controller = new InventoryCommandController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void inventoryMutationRequiresFormPermission() {
var request = move();
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> controller.hold(user(Set.of()), request));
assertEquals(403, exception.getStatusCode().value());
verify(service, never()).hold(any(), any(), any());
}
@Test
void permittedBackOfficeUserCanRunInventoryFormCommand() {
var request = move();
when(service.hold("100", "7", request)).thenReturn(response());
controller.hold(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request);
verify(service).hold("100", "7", request);
}
@Test
void physicalAdjustmentRequiresDedicatedPermission() {
var request = new InventoryCommandRequests.Adjustment("100", 1L, 2, "COUNT", "ORD-1", "adj-1", null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request));
assertEquals(403, exception.getStatusCode().value());
verify(service, never()).adjust(any(), any(), any());
}
private InventoryCommandRequests.Move move() {
return new InventoryCommandRequests.Move("100", 1L, 2, "ORD-1", null, null, "hold-1", null);
}
private AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, 100L, 100L, 8L, "operator", "MERCHANT", "WEB", permissions);
}
private InventoryCommandResponse response() {
return new InventoryCommandResponse(1L, "HOLD", 2L, 3L, 0, 2, 0, 0, false, false);
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.inventory.query.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class InventoryQueryControllerTest {
private final InventoryQueryService service = mock(InventoryQueryService.class);
private final InventoryQueryController controller = controller();
private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery(
1, 20, null, null, null, null, null, null, null, null);
@Test
void merchantCannotOverrideCustomerScope() {
controller.balances(user(100L, "MERCHANT"), "200", query);
verify(service).balances(eq("100"), eq(query));
}
@Test
void platformMustSelectCustomer() {
assertThrows(ResponseStatusException.class,
() -> controller.summary(user(0L, "PLATFORM"), null, query));
}
@Test
void platformCanQuerySelectedCustomer() {
controller.summary(user(0L, "PLATFORM"), "200", query);
verify(service).dashboard(eq("200"), eq(query));
}
@Test
void unauthenticatedPositionDetailIsRejected() {
InventoryQueryRequests.PositionQuery positionQuery =
new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.positions(null, null, 1L, positionQuery));
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of());
}
private InventoryQueryController controller() {
InventoryQueryController controller = new InventoryQueryController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
}
@@ -0,0 +1,131 @@
package com.baiyue.lastleg.inventory.query.service;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InventoryQueryServiceTest {
private final InventoryQueryMapper mapper = mock(InventoryQueryMapper.class);
private final InventoryQueryService service = new InventoryQueryService();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "mapper", mapper);
}
@Test
void dashboardUsesSameTenantAndFiltersForTotalsAndPalletCount() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("AVAILABLE");
InventoryAggregateProjection aggregate = new InventoryAggregateProjection();
aggregate.setDimensionCount(2L);
aggregate.setAvailablePackages(12L);
when(mapper.aggregate("100", query)).thenReturn(aggregate);
when(mapper.countPallets("100", query)).thenReturn(3L);
InventoryDashboardResponse result = service.dashboard("100", query);
assertEquals(2, result.inboundOrderCount());
assertEquals(3, result.palletCount());
assertEquals(12, result.totalPackages());
}
@Test
void balancePageIsBoundToRequestedTenant() {
InventoryQueryRequests.BalanceQuery query = balanceQuery(null);
InventoryBalance balance = new InventoryBalance();
balance.setId(1L);
balance.setCustomerId("100");
when(mapper.countBalances("100", query)).thenReturn(1L);
when(mapper.balances("100", query, 0, 20)).thenReturn(List.of(balance));
service.balances("100", query);
verify(mapper).countBalances("100", query);
verify(mapper).balances("100", query, 0, 20);
}
@Test
void unknownStatusIsRejectedBeforeDatabaseQuery() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED");
assertThrows(ResponseStatusException.class, () -> service.balances("100", query));
verify(mapper, never()).countBalances(any(), any());
}
@Test
void positionDetailDoesNotLeakAnotherTenantBalance() {
InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class, () -> service.positions("100", 99L, query));
verify(mapper, never()).positions(eq("100"), eq(99L), any());
}
@Test
void positionDetailCarriesInboundOrderAndInboundCodeFromOwnedBalance() {
InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null);
InventoryBalance balance = new InventoryBalance();
balance.setId(99L);
balance.setInboundOrderId(88L);
balance.setInboundOrderNo("IBP-20260817-001");
balance.setInboundCode("RK-001");
InventoryPosition position = new InventoryPosition();
position.setBalanceId(99L);
position.setAvailablePackages(7L);
when(mapper.ownedBalance("100", 99L)).thenReturn(balance);
when(mapper.positions("100", 99L, query)).thenReturn(List.of(position));
var result = service.positions("100", 99L, query).get(0);
assertEquals(88L, result.inboundOrderId());
assertEquals("IBP-20260817-001", result.inboundOrderNo());
assertEquals("RK-001", result.inboundCode());
assertEquals(7L, result.totalPackages());
}
@Test
void reversedLedgerDateRangeIsRejected() {
InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery(
1, 20, null, null, null, null, null, null, null, null, null,
java.time.LocalDateTime.of(2026, 8, 18, 0, 0),
java.time.LocalDateTime.of(2026, 8, 17, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.ledgers("100", query));
verify(mapper, never()).countLedgers(any(), any());
}
@Test
void balanceExportUsesInboundOrderPackageColumnsOnly() {
InventoryQueryRequests.BalanceQuery query = balanceQuery(null);
when(mapper.balances("100", query, 0, 10_000)).thenReturn(List.of());
String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8);
assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages"));
assertFalse(csv.contains("skuCode"));
assertFalse(csv.contains("Quantity"));
}
private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) {
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status);
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.procurement;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OverseasProcurementInventoryBoundaryTest {
@Test
void procurementRemainsABusinessDocumentAndDoesNotMoveAvailableInventory() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java"),
StandardCharsets.UTF_8);
assertTrue(source.contains("PENDING_REVIEW"));
assertFalse(source.contains("InventoryCommandService"));
assertFalse(source.contains("stockIn("));
assertFalse(source.contains("setAvailablePackages"));
assertFalse(source.contains("availablePackages"));
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.procurement;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OverseasProcurementSchemaContractTest {
@Test
void baselineContainsOrderOutboundAndReviewHistoryTables() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no)"));
}
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
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 java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class OverseasProcurementControllerTest {
@Mock OverseasProcurementService service;
private MerchantOverseasProcurementController merchant;
private PlatformOverseasProcurementController platform;
@BeforeEach
void setUp() {
merchant = new MerchantOverseasProcurementController();
platform = new PlatformOverseasProcurementController();
ReflectionTestUtils.setField(merchant, "service", service);
ReflectionTestUtils.setField(platform, "service", service);
}
@Test
void merchantAlwaysUsesTenantFromTrustedIdentity() {
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query);
}
@Test
void reviewRequiresExplicitPermission() {
var request = new OverseasProcurementRequests.Review("APPROVE", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
assertEquals(403, error.getStatusCode().value());
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> merchant.page(user(0L, "PLATFORM", Set.of()), query));
assertEquals(403, error.getStatusCode().value());
}
@Test
void platformReviewUsesExplicitTargetTenant() {
var request = new OverseasProcurementRequests.Review("APPROVE", "ok");
platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)),
"100", "HC1", request);
verify(service).review("100", "HC1", "7", request);
}
private AuthenticatedUser user(long tenantId, String appCode, Set<String> permissions) {
return new AuthenticatedUser(7L, tenantId, tenantId, 8L, "operator", appCode, "WEB", permissions);
}
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementOutboundMapper outbounds;
@Mock OverseasProcurementStatusHistoryMapper histories;
private OverseasProcurementService service;
@BeforeEach
void setUp() {
service = new OverseasProcurementService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "outbounds", outbounds);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@Test
void customerOrderAlwaysStartsPendingReviewWithZeroInventory() {
AtomicReference<OverseasProcurementOrder> saved = new AtomicReference<>();
when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> {
saved.set(invocation.getArgument(0));
return 1;
});
when(orders.findNo(anyString(), anyString())).thenAnswer(ignored -> saved.get());
when(outbounds.listOrder(anyString(), any())).thenReturn(List.of());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", new OverseasProcurementRequests.Create(
"Acme", "Office", "Labels", "100x150", "https://example.test/product", 500L,
new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1"));
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status());
assertEquals(0, detail.summary().inboundQuantity());
assertEquals(0, detail.summary().inventoryQuantity());
}
@Test
void pendingOrderCannotReceiveInventory() {
OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0);
when(orders.lockNo("100", "HC1")).thenReturn(order);
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
100L, null, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "HC1", request));
assertEquals(409, error.getStatusCode().value());
}
@Test
void outboundCannotExceedDerivedInventory() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 80);
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.addOutbound(order.getId(), "100", 30)).thenReturn(0);
var request = new OverseasProcurementRequests.AddOutbound(30L, "OUT-1", null, null,
LocalDateTime.now(), null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.addOutbound("100", "HC1", "7", request));
assertEquals(409, error.getStatusCode().value());
}
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
OverseasProcurementOrder order = new OverseasProcurementOrder();
order.setId(1L);
order.setCustomerId("100");
order.setOrderNo("HC1");
order.setStatus(status);
order.setInboundQuantity(inbound);
order.setOutboundQuantity(outbound);
order.setPurchaseQuantity(500L);
order.setVersion(0L);
order.setCreatedAt(LocalDateTime.now());
order.setUpdatedAt(order.getCreatedAt());
return order;
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.relabel;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import org.junit.jupiter.api.Test;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RelabelContractTest {
@Test
void createHeaderDoesNotExposeWaybillLabelFileAndItemCarriesRequestedFileFields() {
assertFalse(Arrays.stream(RelabelRequests.Create.class.getRecordComponents())
.map(RecordComponent::getName)
.anyMatch(name -> name.toLowerCase().contains("waybill") || name.toLowerCase().contains("labelFile")));
assertTrue(Arrays.stream(RelabelRequests.Item.class.getRecordComponents())
.map(RecordComponent::getName).toList().containsAll(Arrays.asList("skuCode", "productImageFileId",
"productNameZh", "totalPackageCount", "notifiedPackageCount", "shipmentNo", "trackingCode",
"newSkuCode", "labelRequirement", "relabelFileId")));
}
@Test
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();
for (String schema : new String[] { baseline, migration }) {
assertTrue(schema.contains("ll_relabel_application"));
assertTrue(schema.contains("ll_relabel_item"));
assertTrue(schema.contains("ll_relabel_status_history"));
assertTrue(schema.contains("customer_id"));
assertTrue(schema.contains("idempotency_key"));
assertTrue(schema.contains("request_hash"));
}
}
}
@@ -0,0 +1,259 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
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 java.time.LocalDateTime;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RelabelApplicationServiceTest {
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@Mock RelabelStatusHistoryMapper histories;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock LastlegOrderNoGenerator orderNoGenerator;
private RelabelApplicationService service;
@BeforeEach
void setUp() {
service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(orderNoGenerator.next()).thenReturn("LMO-RELABEL-1");
}
@Test
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6);
RelabelApplication persisted = application(11L, "FBR-1", "TENANT-A", "key-1", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L);
persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6);
when(applications.findNo(eq("TENANT-A"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "TENANT-A", "LMO-RELABEL-1",
RelabelApplicationService.PENDING_PROCESSING));
RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request);
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
ArgumentCaptor<RelabelItem> relabelItem = ArgumentCaptor.forClass(RelabelItem.class);
ArgumentCaptor<RelabelStatusHistory> relabelHistory = ArgumentCaptor.forClass(RelabelStatusHistory.class);
ArgumentCaptor<LastlegOrderStatusHistory> tailHistory = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(orders).insert(tailOrder.capture());
verify(applications).insert(application.capture());
verify(items).insert(relabelItem.capture());
verify(histories).insert(relabelHistory.capture());
verify(orderHistories).insert(tailHistory.capture());
assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertEquals("LMO-RELABEL-1", tailOrder.getValue().getOrderNo());
assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId());
assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo());
assertTrue(tailOrder.getValue().getInboundCode().startsWith("FBR"));
assertEquals(10, tailOrder.getValue().getPackageCount());
assertEquals(6, application.getValue().getNotifiedPackageCount());
assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus());
assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus());
assertEquals("FBR-1", result.application().relabelNo());
}
@Test
void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() {
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.create("TENANT-A", "operator-1", request("key-1", 5, 6)));
assertEquals(400, exception.getStatusCode().value());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(applications, never()).insert(any(RelabelApplication.class));
verify(items, never()).insert(any(RelabelItem.class));
}
@Test
void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() {
RelabelRequests.Create request = request("key-1", 10, 6);
RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", hash(request),
RelabelApplicationService.PENDING_PROCESSING);
existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10);
existing.setNotifiedPackageCount(6);
when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing);
stubDetail(existing);
RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request);
assertEquals("FBR-1", result.application().relabelNo());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(applications, never()).insert(any(RelabelApplication.class));
verify(items, never()).insert(any(RelabelItem.class));
}
@Test
void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() {
RelabelRequests.Create request = request("key-1", 10, 6);
RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", "different-hash",
RelabelApplicationService.PENDING_PROCESSING);
when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.create("TENANT-A", "operator-1", request));
assertEquals(409, exception.getStatusCode().value());
verify(orders, never()).insert(any(LastlegOrder.class));
}
@Test
void statusChangeIsTenantScopedAndWritesApplicationAndTailOrderHistory() {
RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash",
RelabelApplicationService.PENDING_PROCESSING);
application.setLastlegOrderId(21L);
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application);
stubDetail(application);
RelabelResponses.Detail result = service.changeStatus("TENANT-A", "FBR-1", "operator-2",
new RelabelRequests.ChangeStatus("processing", "started"));
assertEquals("PROCESSING", application.getStatus());
assertEquals("PROCESSING", result.application().status());
verify(applications).updateById(application);
ArgumentCaptor<LastlegOrder> updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).updateById(updatedOrder.capture());
assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus());
verify(histories).insert(any(RelabelStatusHistory.class));
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() {
RelabelApplication completed = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "COMPLETED");
completed.setLastlegOrderId(21L);
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(completed);
ResponseStatusException transition = assertThrows(ResponseStatusException.class, () ->
service.changeStatus("TENANT-A", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("PROCESSING", null)));
assertEquals(409, transition.getStatusCode().value());
when(applications.findNo("TENANT-B", "FBR-1")).thenReturn(null);
ResponseStatusException boundary = assertThrows(ResponseStatusException.class, () ->
service.detail("TENANT-B", "FBR-1"));
assertEquals(404, boundary.getStatusCode().value());
verify(applications).findNo("TENANT-B", "FBR-1");
}
@Test
void completedReturnRelabelOrderEntersPendingAssignment() {
RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "PROCESSING");
application.setLastlegOrderId(21L);
LastlegOrder order = order(21L, "TENANT-A", "LMO-RELABEL-1", "PROCESSING");
order.setOrderType("RETURN_RELABEL");
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application);
stubDetail(application);
when(orders.selectById(21L)).thenReturn(order);
service.changeStatus("TENANT-A", "FBR-1", "operator-2",
new RelabelRequests.ChangeStatus("COMPLETED", "finished"));
assertEquals("COMPLETED", application.getStatus());
assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus());
assertEquals("COMPLETED", order.getRelabelStatus());
}
private void stubDetail(RelabelApplication application) {
when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(application.getLastlegOrderId())).thenReturn(order(application.getLastlegOrderId(),
application.getCustomerId(), "LMO-RELABEL-1", application.getStatus()));
}
private static RelabelRequests.Create request(String key, int total, int notified) {
return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0),
"Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1",
"image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1",
"SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf")));
}
private static RelabelApplication application(long id, String no, String customerId, String key, String requestHash,
String status) {
RelabelApplication value = new RelabelApplication();
value.setId(id); value.setCustomerId(customerId); value.setRelabelNo(no); value.setIdempotencyKey(key);
value.setRequestHash(requestHash); value.setStatus(status); value.setCreatedAt(LocalDateTime.now());
value.setContactName("Li"); value.setContactPhone("13800000000"); value.setCustomerOrderNo("CUSTOMER-1");
value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1));
value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6);
return value;
}
private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) {
RelabelItem value = new RelabelItem();
value.setId(id); value.setApplicationId(applicationId); value.setCustomerId("TENANT-A"); value.setSkuCode(sku);
value.setProductNameZh("Chinese name"); value.setTotalPackageCount(total); value.setNotifiedPackageCount(notified);
value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW");
return value;
}
private static LastlegOrder order(long id, String customerId, String orderNo, String status) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setCustomerId(customerId); value.setOrderNo(orderNo); value.setCurrentStatus(status);
return value;
}
private static String hash(RelabelRequests.Create request) {
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(mapper().writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (Exception exception) {
throw new AssertionError(exception);
}
}
private static ObjectMapper mapper() {
return new ObjectMapper().findAndRegisterModules();
}
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.schema;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SchemaChineseCommentContractTest {
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
private static final Pattern TABLE = Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)"
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;");
@Test
void allLastlegTablesAndColumnsHaveChineseComments() throws Exception {
String baseline = Files.readString(
Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8);
String incremental = Files.readString(
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<TableDefinition> baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55);
assertAllChineseComments(incremental, "增量 SQL", 49);
assertCommentMigrationCovers(incremental, baselineTables);
}
@Test
void newlyAddedColumnsCarryChineseCommentsInBaselineAndIncrementalSql() throws Exception {
String baseline = Files.readString(
Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8);
String incremental = Files.readString(
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<String> columns = List.of(
"allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key",
"outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count",
"allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key",
"receipt_request_hash"
);
for (String column : columns) {
assertColumnHasChineseComment(baseline, column, "基线 SQL");
assertColumnHasChineseComment(incremental, column, "增量 SQL");
assertIncrementalAddColumnHasChineseComment(incremental, column);
}
}
private static List<TableDefinition> assertAllChineseComments(String sql, String source, int expectedCount) {
List<TableDefinition> tables = new ArrayList<>();
Matcher matcher = TABLE.matcher(sql);
while (matcher.find()) {
String table = matcher.group(1);
assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
List<String> columns = splitTopLevel(matcher.group(2));
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
List<String> columnNames = new ArrayList<>();
for (String definition : columns) {
if (isConstraint(definition)) {
continue;
}
String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", "");
columnNames.add(column);
assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'")
.matcher(definition).find(),
source + " 字段备注必须包含中文: " + table + "." + column);
}
tables.add(new TableDefinition(table, columnNames));
}
assertTrue(tables.size() >= expectedCount,
source + " 解析到的尾程表数量不足,实际=" + tables.size() + ",期望至少=" + expectedCount);
return tables;
}
private static void assertCommentMigrationCovers(String incremental, List<TableDefinition> tables) {
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移");
String migration = incremental.substring(marker);
for (TableDefinition table : tables) {
assertTrue(migration.contains("table_name='" + table.name() + "'"),
"025 备注迁移缺少表: " + table.name());
for (String column : table.columns()) {
assertTrue(migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'"),
"025 备注迁移缺少字段: " + table.name() + "." + column);
}
}
}
private static boolean isConstraint(String definition) {
String normalized = definition.stripLeading().toUpperCase();
return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ")
|| normalized.startsWith("KEY ") || normalized.startsWith("CONSTRAINT ")
|| normalized.startsWith("FOREIGN ") || normalized.startsWith("CHECK ");
}
private static void assertColumnHasChineseComment(String sql, String column, String source) {
Pattern columnPattern = Pattern.compile(
"(?is)\\b" + Pattern.quote(column) + "\\b[^,;]*?COMMENT\\s+''?[^',;]*[\\u4e00-\\u9fff][^',;]*''?");
assertTrue(columnPattern.matcher(sql).find(), source + " 新增字段缺少中文备注: " + column);
}
private static void assertIncrementalAddColumnHasChineseComment(String sql, String column) {
Pattern addColumnPattern = Pattern.compile(
"(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column)
+ "\\b[^;]*?COMMENT\\s+''[^';]*[\\u4e00-\\u9fff][^';]*''");
assertTrue(addColumnPattern.matcher(sql).find(), "增量 SQL 的 ADD COLUMN 缺少中文备注: " + column);
}
private static List<String> splitTopLevel(String body) {
List<String> definitions = new ArrayList<>();
int start = 0;
int parentheses = 0;
boolean quoted = false;
for (int i = 0; i < body.length(); i++) {
char current = body.charAt(i);
if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) {
quoted = !quoted;
} else if (!quoted && current == '(') {
parentheses++;
} else if (!quoted && current == ')') {
parentheses--;
} else if (!quoted && parentheses == 0 && current == ',') {
definitions.add(body.substring(start, i).trim());
start = i + 1;
}
}
definitions.add(body.substring(start).trim());
return definitions.stream().filter(value -> !value.isEmpty()).toList();
}
private record TableDefinition(String name, List<String> columns) {
}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.security;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
class InternalServiceTokenGuardTest {
@Test void acceptsOnlyConfiguredToken() {
InternalServiceTokenGuard guard=new InternalServiceTokenGuard("secret-token");
assertDoesNotThrow(()->guard.verify("secret-token"));
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value());
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value());
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.concurrent.*;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class LastlegOrderNoGeneratorTest {
@Test void actualContainerNumberIsStableAndShared() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633"));
assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1"));
assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1"));
assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32);
assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" "));
}
@Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception {
LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator();
String first=beforeRestart.next();
LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator();
assertNotEquals(first,afterRestart.next());
ExecutorService pool=Executors.newFixedThreadPool(8);
try {
var tasks=IntStream.range(0,1000).mapToObj(i->(Callable<String>)afterRestart::next).toList();
Set<String> ids=pool.invokeAll(tasks).stream().map(future->{
try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); }
}).collect(java.util.stream.Collectors.toSet());
assertEquals(1000,ids.size());
} finally { pool.shutdownNow(); }
}
}
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
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.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
@Mock DropshipCartonMapper dropshipCartons;
LastlegOrderService service;
@BeforeEach
void setUp() {
service = new LastlegOrderService();
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
}
@Test
void detailGroupsDropshipSkuAndCartonsByPallet() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectOne(any())).thenReturn(order);
when(details.selectList(any())).thenReturn(List.of());
when(histories.selectList(any())).thenReturn(List.of());
DropshipOrderProfile first = profile(1L, "SOURCE-P-1", 101L, "WH-P-1");
DropshipOrderProfile second = profile(2L, "SOURCE-P-2", 102L, "WH-P-2");
when(dropshipProfiles.listOrder("100", 11L)).thenReturn(List.of(first, second));
DropshipSkuInventory sku = new DropshipSkuInventory();
sku.setProfileId(1L); sku.setSkuCode("SKU-A"); sku.setPackageCount(1L); sku.setProductQuantity(10L);
when(dropshipSkuInventory.listOrder("100", 11L)).thenReturn(List.of(sku));
DropshipCarton carton = new DropshipCarton();
carton.setProfileId(1L); carton.setSkuCode("SKU-A"); carton.setCartonMark("MARK-1");
when(dropshipCartons.listOrder("100", 11L)).thenReturn(List.of(carton));
var response = service.detail("100", 11L);
assertEquals(2, response.dropshipPallets().size());
assertEquals("SOURCE-P-1", response.dropshipPallets().get(0).sourcePalletNo());
assertEquals(1, response.dropshipPallets().get(0).skuInventory().size());
assertEquals("MARK-1", response.dropshipPallets().get(0).cartons().get(0).getCartonMark());
assertTrue(response.dropshipPallets().get(1).skuInventory().isEmpty());
}
@Test
void transferUpdatesOrderAndEveryCargoSnapshot() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
assertEquals(1, service.transferDestination("100", request));
assertEquals("wh-2", order.getTransferWarehouseId());
assertEquals("LAX-02", order.getTransferWarehouseCode());
assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus());
ArgumentCaptor<LastlegOrderDetail> cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class);
verify(details).update(cargo.capture(), any());
assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus());
assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus());
}
@Test
void transferRejectsAnOrderAlreadyBoundToInboundPlan() {
LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1");
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request));
verify(orders, never()).updateById(any(LastlegOrder.class));
verify(details, never()).update(any(), any());
}
@Test
void batchRejectsMissingOrCrossCustomerIdsBeforeWriting() {
when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null)));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
assertTrue(error.getStatusCode().is4xxClientError());
verify(orders, never()).updateById(any(LastlegOrder.class));
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
order.setCustomerId(customerId);
order.setCurrentStatus(status);
order.setInboundOrderNo(inboundOrderNo);
return order;
}
private static DropshipOrderProfile profile(long id, String sourcePalletNo,
long warehousePalletId, String palletNo) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(id); profile.setSourcePalletNo(sourcePalletNo);
profile.setWarehousePalletId(warehousePalletId); profile.setPalletNo(palletNo);
profile.setStatus("RECEIVED");
return profile;
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); 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.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@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","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","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()); assertEquals("100",captor.getValue().customerId()); 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()); }
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.transfer;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WarehouseTransferSchemaContractTest {
@Test
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() 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();
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order",
"ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation",
"ll_warehouse_transfer_receipt"}) {
assertTrue(baseline.contains("create table if not exists " + table));
assertTrue(migration.contains("create table if not exists " + table));
}
assertTrue(migration.contains("information_schema.statistics"));
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
assertTrue(migration.contains("uk_ll_wh_transfer_active_position"));
assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source"));
}
}
@@ -0,0 +1,371 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
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 java.time.LocalDateTime;
import java.math.BigDecimal;
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;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehouseTransferServiceTest {
@Mock WarehouseTransferOrderMapper transfers;
@Mock WarehouseTransferItemMapper transferItems;
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Mock WarehouseTransferReceiptMapper transferReceipts;
@Mock InboundSnapshotMapper inboundSnapshots;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock RelabelApplicationMapper relabelApplications;
@Mock RelabelItemMapper relabelItems;
@Mock RelabelStatusHistoryMapper relabelHistories;
@Mock LastlegOrderNoGenerator orderNoGenerator;
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
private WarehouseTransferService service;
@BeforeEach
void setUp() {
service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "transfers", transfers);
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(orderNoGenerator.next()).thenReturn("LMO-TRANSFER-1");
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
}
@Test
void relabelTransferLinksHistoricalOrderAndInboundAndCreatesOnePendingReviewTailOrder() {
WarehouseTransferRequests.Create request = request(true);
LastlegOrder historical = historicalOrder();
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historical));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.create("TENANT-A", "USER-1", request);
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(tailOrder.capture());
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo());
assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications).insert(any(RelabelApplication.class));
verify(relabelItems).insert(any(RelabelItem.class));
verify(relabelHistories).insert(any(RelabelStatusHistory.class));
verify(orders).updateById(tailOrder.getValue());
assertNotNull(tailOrder.getValue().getRelabelOrderNo());
assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus());
}
@Test
void transferWithoutRelabelDoesNotCreateRelabelDocument() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.create("TENANT-A", "USER-1", request(false));
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
verify(relabelItems, never()).insert(any(RelabelItem.class));
verify(relabelHistories, never()).insert(any(RelabelStatusHistory.class));
assertNull(result.relabelNo());
}
@Test
void approvedTransferWithoutRelabelEntersPendingAssignment() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
transfer.setNeedRelabel(false);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.review("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Review("APPROVE", "approved"));
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verifyNoInteractions(inventory);
}
@Test
void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() {
WarehouseTransferOrder transfer = transfer("APPROVED");
LastlegOrder tailOrder = tailOrder();
WarehouseTransferSourceOrder source = sourceOrder();
InventoryPosition position = position(501L, 601L, 701L, 5L);
InventoryBalance balance = balance(601L, 101L, "IN-1");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(sourceOrders.selectList(any())).thenReturn(List.of(source));
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position);
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
var result = service.allocate("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Allocate("ALLOC-1",
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5))));
ArgumentCaptor<WarehouseTransferInventoryAllocation> captured =
ArgumentCaptor.forClass(WarehouseTransferInventoryAllocation.class);
verify(inventoryAllocations).insert(captured.capture());
assertEquals(101L, captured.getValue().getInboundOrderId());
assertEquals(701L, captured.getValue().getPalletId());
assertEquals(5, captured.getValue().getPackageCount());
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("WAIT_OUTBOUND", result.orderStatus());
verifyNoInteractions(inventory);
}
@Test
void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
LastlegOrder tailOrder = tailOrder();
WarehouseTransferInventoryAllocation allocation = allocation();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
var ordered = inOrder(inventory);
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move> move =
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move.class);
ordered.verify(inventory).allocate(eq("TENANT-A"), eq("USER-1"), move.capture());
ordered.verify(inventory).pick(eq("TENANT-A"), eq("USER-1"), move.capture());
ordered.verify(inventory).ship(eq("TENANT-A"), eq("USER-1"), move.capture());
assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5));
assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus());
assertEquals("SHIPPED", allocation.getStatus());
clearInvocations(inventory);
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
verifyNoInteractions(inventory);
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(null);
when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn(
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn> stockIn =
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
assertEquals(5, stockIn.getValue().packages());
assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus());
assertEquals("RECEIVED", allocation.getStatus());
ArgumentCaptor<com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt> receipt =
ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class);
verify(transferReceipts).insert(receipt.capture());
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(receipt.getValue());
clearInvocations(inventory);
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
verifyNoInteractions(inventory);
}
@Test
void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
WarehouseTransferInventoryAllocation allocation = allocation();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "insufficient"))
.when(inventory).pick(eq("TENANT-A"), eq("USER-1"), any());
assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", null)));
verify(inventory, never()).ship(eq("TENANT-A"), eq("USER-1"), any());
verify(inventoryAllocations, never()).updateById(any(WarehouseTransferInventoryAllocation.class));
verify(transfers, never()).updateById(transfer);
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("RESERVED", allocation.getStatus());
}
@Test
void allocationRejectsPositionOutsideSelectedInboundOrder() {
WarehouseTransferOrder transfer = transfer("APPROVED");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder()));
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position(501L, 601L, 701L, 5L));
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 999L, "OTHER"));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.allocate("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Allocate("ALLOC-1",
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5)))));
assertEquals(409, exception.getStatusCode().value());
verify(inventoryAllocations, never()).insert(any(WarehouseTransferInventoryAllocation.class));
verifyNoInteractions(inventory);
}
@Test
void selectedOrderMustBelongToTenantAndHaveInboundBinding() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of());
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request(false)));
assertEquals(404, exception.getStatusCode().value());
verify(transfers, never()).insert(any(WarehouseTransferOrder.class));
verify(orders, never()).insert(any(LastlegOrder.class));
}
private static WarehouseTransferRequests.Create request(boolean needRelabel) {
return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8",
"123 FBA Road", 5, new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1",
"TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
}
private static LastlegOrder historicalOrder() {
LastlegOrder order = new LastlegOrder();
order.setId(11L); order.setCustomerId("TENANT-A"); order.setOrderNo("LMO-OLD-1"); order.setInboundCode("IN-1");
return order;
}
private static WarehouseTransferOrder persistedTransfer() {
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(21L); transfer.setCustomerId("TENANT-A"); transfer.setTransferNo("WTR-1");
transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L);
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5);
return transfer;
}
private static WarehouseTransferOrder transfer(String status) {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setTransferNo("WTR-1"); transfer.setStatus(status); transfer.setPackageCount(5);
return transfer;
}
private static LastlegOrder tailOrder() {
LastlegOrder order = historicalOrder();
order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT");
return order;
}
private static WarehouseTransferSourceOrder sourceOrder() {
WarehouseTransferSourceOrder source = new WarehouseTransferSourceOrder();
source.setId(41L); source.setTransferOrderId(21L); source.setCustomerId("TENANT-A");
source.setInboundOrderId(101L); source.setInboundCode("IN-1");
return source;
}
private static InventoryPosition position(long id, long balanceId, long palletId, long available) {
InventoryPosition position = new InventoryPosition();
position.setId(id); position.setCustomerId("TENANT-A"); position.setBalanceId(balanceId);
position.setPalletId(palletId); position.setPalletNo("PALLET-1"); position.setAvailablePackages(available);
return position;
}
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
InventoryBalance balance = new InventoryBalance();
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
balance.setInboundCode(inboundCode);
return balance;
}
private static WarehouseTransferInventoryAllocation allocation() {
WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation();
allocation.setId(51L); allocation.setTransferOrderId(21L); allocation.setCustomerId("TENANT-A");
allocation.setInventoryPositionId(501L); allocation.setInventoryBalanceId(601L);
allocation.setInboundOrderId(101L); allocation.setInboundCode("IN-1"); allocation.setPalletId(701L);
allocation.setPalletNo("PALLET-1"); allocation.setPackageCount(5); allocation.setStatus("RESERVED");
return allocation;
}
private static WarehouseTransferRequests.Receipt receiptRequest() {
return new WarehouseTransferRequests.Receipt(501L, "WH-DEST", "WH-D", "Destination warehouse",
"FBA-ID", "ONT8", "Amazon ONT8", 801L, "PALLET-DEST", "WTR-1", 901L,
"LOC-DEST", "RECEIVE-1", "received");
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.trucking;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class UsTruckOrderSchemaContractTest {
@Test
void baselineContainsOrderAndImmutableHistoryTables() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet"));
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1"));
assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("receipt_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.trucking.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.service.UsTruckOrderService;
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 java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class UsTruckOrderControllerTest {
@Mock UsTruckOrderService service;
private MerchantUsTruckOrderController merchant;
private PlatformUsTruckOrderController platform;
@BeforeEach
void setUp() {
merchant = new MerchantUsTruckOrderController(); platform = new PlatformUsTruckOrderController();
ReflectionTestUtils.setField(merchant, "service", service); ReflectionTestUtils.setField(platform, "service", service);
}
@Test
void merchantListAlwaysUsesTrustedTenant() {
var query = query(); merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query);
}
@Test
void platformStatusChangeRequiresManagePermission() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request));
assertEquals(403, error.getStatusCode().value());
}
@Test
void platformStatusChangeUsesExplicitTargetTenant() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned");
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
verify(service).changeStatus("100", "TK1", "7", request);
}
private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); }
private AuthenticatedUser user(long tenant, String app, Set<String> permissions) {
return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions);
}
}
@@ -0,0 +1,142 @@
package com.baiyue.lastleg.trucking.service;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UsTruckOrderServiceTest {
@Mock UsTruckOrderMapper orders;
@Mock UsTruckOrderPalletMapper pallets;
@Mock UsTruckOrderStatusHistoryMapper histories;
private UsTruckOrderService service;
@BeforeEach
void setUp() {
service = new UsTruckOrderService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of());
}
@Test
void customerOrderStartsPendingConfirmationAndUsesUsdByDefault() {
AtomicReference<UsTruckOrder> saved = new AtomicReference<>();
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { saved.set(call.getArgument(0)); return 1; });
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", create("key-1"));
assertEquals(UsTruckOrderService.PENDING_CONFIRMATION, detail.summary().status());
assertEquals("USD", detail.summary().currency());
assertEquals("100", detail.summary().customerId());
}
@Test
void statusChangePersistsHistoryAndIncrementsVersion() {
UsTruckOrder order = order(UsTruckOrderService.PENDING_CONFIRMATION, 0L);
when(orders.lockNo("100", "TK1")).thenReturn(order);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var result = service.changeStatus("100", "TK1", "7",
new UsTruckOrderRequests.ChangeStatus(0L, "WAITING_INBOUND", "accepted"));
assertEquals(UsTruckOrderService.WAITING_INBOUND, result.summary().status());
assertEquals(1, result.summary().version());
verify(histories).insert(any(UsTruckOrderStatusHistory.class));
}
@Test
void staleStatusChangeIsRejected() {
when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.DISPATCHED, 2L));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.changeStatus("100", "TK1", "7", new UsTruckOrderRequests.ChangeStatus(1L, "IN_TRANSIT", null)));
assertEquals(409, error.getStatusCode().value());
}
@Test
void detailIncludesBoundPallets() {
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L);
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
pallet.setId(10L); pallet.setPalletNo("PLT-001"); pallet.setSequenceNo(1);
pallet.setSource("SYSTEM"); pallet.setStatus("OCCUPIED");
pallet.setWarehousePalletId(20L); pallet.setPackageCount(12);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
when(pallets.listOrder("100", 1L)).thenReturn(List.of(pallet));
var detail = service.detail("100", "TK1");
assertEquals(1, detail.pallets().size());
assertEquals("PLT-001", detail.pallets().get(0).palletNo());
assertEquals(12, detail.pallets().get(0).packageCount());
}
@Test
void receivingMorePalletsCreatesOverageAndKeepsDeclaredCount() {
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 0L);
order.setPalletCount(1);
when(orders.lockNo("100", "TK1")).thenReturn(order);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
List<UsTruckOrderPallet> savedPallets = new ArrayList<>();
when(pallets.listOrder("100", 1L)).thenAnswer(call -> List.copyOf(savedPallets));
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
savedPallets.add(call.getArgument(0));
return 1;
});
var result = service.receivePallets("100", "TK1", "7",
new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets"));
assertEquals(1, result.summary().palletCount());
assertEquals(3, result.summary().receivedPalletCount());
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status());
verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class));
}
private UsTruckOrderRequests.Create create(String key) {
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA",
"Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"),
new BigDecimal("3800"), null, "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
}
private UsTruckOrder order(String status, long version) {
UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1");
order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0);
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
order.setStatus(status); order.setVersion(version);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order;
}
}