暂存提交
This commit is contained in:
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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"));
|
||||
}
|
||||
}
|
||||
+297
@@ -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;
|
||||
}
|
||||
}
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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)"));
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+108
@@ -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;
|
||||
}
|
||||
}
|
||||
+180
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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"));
|
||||
}
|
||||
}
|
||||
+121
@@ -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());
|
||||
}
|
||||
}
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
+112
@@ -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");
|
||||
}
|
||||
}
|
||||
+35
@@ -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");
|
||||
}
|
||||
}
|
||||
+148
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+374
@@ -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;
|
||||
}
|
||||
}
|
||||
+214
@@ -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) {}
|
||||
}
|
||||
+137
@@ -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;
|
||||
}
|
||||
}
|
||||
+143
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -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)));
|
||||
}
|
||||
}
|
||||
+121
@@ -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);
|
||||
}
|
||||
}
|
||||
+165
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+131
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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"));
|
||||
}
|
||||
}
|
||||
+21
@@ -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)"));
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -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();
|
||||
}
|
||||
}
|
||||
+140
@@ -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) {
|
||||
}
|
||||
}
|
||||
+12
@@ -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());
|
||||
}
|
||||
}
|
||||
+31
@@ -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(); }
|
||||
}
|
||||
}
|
||||
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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); }
|
||||
}
|
||||
+25
@@ -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"));
|
||||
}
|
||||
}
|
||||
+371
@@ -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");
|
||||
}
|
||||
}
|
||||
+25
@@ -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)"));
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+142
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user