暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DeliverySchemaContractTest {
@Test
void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception {
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(schema.contains("create table if not exists ll_outbound_order"));
assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'"));
assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)"));
assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)"));
assertTrue(schema.contains("destination_warehouse_code varchar(128) null"));
assertTrue(schema.contains("order_refs varchar(2000) null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
assertTrue(schema.contains("create table if not exists ll_delivery_order"));
assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'"));
assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_delivery_outbound (customer_id,outbound_order_id)"));
}
@Test
void deliveryPersistenceEntitiesUseSnowflakeIds() throws Exception {
for (Class<?> type : new Class<?>[] { OutboundOrder.class, OutboundPalletItem.class, DeliveryOrder.class }) {
TableId id = type.getDeclaredField("id").getAnnotation(TableId.class);
assertNotNull(id, type.getSimpleName());
assertEquals(IdType.ASSIGN_ID, id.type(), type.getSimpleName());
}
}
@Test
void incrementalMigrationAddsTransferTablesAndIsRerunnable() throws Exception {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(migration.contains("2026-08-17 / 014"));
assertTrue(migration.contains("create table if not exists ll_outbound_order"));
assertTrue(migration.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(migration.contains("create table if not exists ll_delivery_order"));
}
}
@@ -0,0 +1,297 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DeliveryTransferServiceTest {
@Mock DispatchOrderMapper dispatchOrders;
@Mock DispatchItemMapper dispatchItems;
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundPallets;
@Mock DeliveryOrderMapper deliveryOrders;
@Mock InboundPlanPalletAllocationMapper planAllocations;
@Mock InboundPlanLineMapper planLines;
@Mock LastlegOrderMapper tailOrders;
@Mock LastlegOrderStatusHistoryMapper histories;
private DeliveryTransferService service;
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders);
ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
}
@Test
void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12),
item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8)
));
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer("transfer-101", "ready"));
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
ArgumentCaptor<DeliveryOrder> delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
ArgumentCaptor<OutboundPalletItem> pallets = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundOrders).insert(outbound.capture());
verify(deliveryOrders).insert(delivery.capture());
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture());
assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus());
assertEquals(2, outbound.getValue().getPalletCount());
assertEquals(20, outbound.getValue().getPackageCount());
assertEquals("transfer-101", outbound.getValue().getIdempotencyKey());
assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT"));
assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId());
assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus());
assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL"));
assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream()
.map(OutboundPalletItem::getDestinationWarehouseCode).toList());
assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream()
.map(OutboundPalletItem::getInboundCode).toList());
assertEquals(20, result.packageCount());
assertEquals(2, result.palletCount());
assertTrue(!result.replayed());
}
@Test
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer(" transfer-101 ", null));
assertTrue(result.replayed());
assertEquals("OUT-301", result.outboundNo());
assertEquals("DEL-401", result.deliveryNo());
verify(outboundOrders, never()).insert((OutboundOrder) any());
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
}
@Test
void idempotencyKeyCannotBeReusedForAnotherDispatch() {
DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A");
OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch);
when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null)));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void concurrentDuplicateInsertReturnsWinningDocuments() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, winner, "DEL-401");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
new DeliveryRequests.Transfer("transfer-101", null));
assertTrue(result.replayed());
assertEquals("OUT-301", result.outboundNo());
assertEquals("DEL-401", result.deliveryNo());
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
}
@Test
void directDispatchOrderReferenceIsBoundToCreatedDocuments() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
source.setOrderId(901L);
LastlegOrder tailOrder = new LastlegOrder();
tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source));
when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder));
service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null));
assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus());
assertNotNull(tailOrder.getOutboundOrderNo());
assertNotNull(tailOrder.getDeliveryOrderNo());
verify(tailOrders).updateById(tailOrder);
verify(histories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void transferRejectsAPlannedPalletThatHasNotBeenReceived() {
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12);
planned.setSourceStage("PLAN");
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null)));
assertEquals(409, exception.getStatusCode().value());
assertTrue(exception.getReason().contains("received"));
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8);
when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") Page<OutboundPalletItem> page = invocation.getArgument(0);
page.setRecords(List.of(first, second));
page.setTotal(2);
return page;
});
DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
PageResult<DeliveryResponses.PalletRow> pallets = detail.pallets();
assertEquals(2, pallets.total());
assertEquals(2, pallets.records().size());
assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode());
assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName());
assertEquals(12, pallets.records().get(0).packageCount());
assertEquals("IN-A", pallets.records().get(0).inboundCode());
assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode());
assertEquals(8, pallets.records().get(1).packageCount());
}
@Test
void detailLookupCannotCrossTenantBoundary() {
when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)));
assertEquals(404, exception.getStatusCode().value());
verify(outboundOrders).findByNo("TENANT-B", "OUT-301");
verify(deliveryOrders, never()).lockByOutbound(any(), any());
verify(outboundPallets, never()).selectPage(any(), any());
}
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {
DispatchOrder value = new DispatchOrder();
value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART");
return value;
}
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
String destinationName, String inboundCode, int packages) {
DispatchItem value = new DispatchItem();
value.setId(id); value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo);
value.setDestinationWarehouseId("DEST-" + destinationCode);
value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName);
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
value.setSourceStage("RECEIVED");
return value;
}
private static OutboundOrder outbound(Long id, Long dispatchId, String dispatchNo, String outboundNo,
String idempotencyKey) {
OutboundOrder value = new OutboundOrder();
value.setId(id); value.setCustomerId("TENANT-A"); value.setDispatchOrderId(dispatchId);
value.setDispatchNo(dispatchNo); value.setOutboundNo(outboundNo); value.setStatus("WAIT_OUTBOUND");
value.setOverseasWarehouseId("WH-US-1"); value.setPalletCount(2); value.setPackageCount(20);
value.setIdempotencyKey(idempotencyKey); value.setCreatedAt(LocalDateTime.now());
return value;
}
private static DeliveryOrder delivery(Long id, OutboundOrder outbound, String deliveryNo) {
DeliveryOrder value = new DeliveryOrder();
value.setId(id); value.setCustomerId(outbound.getCustomerId()); value.setDeliveryNo(deliveryNo);
value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo());
value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo());
value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY");
value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount());
value.setCreatedAt(LocalDateTime.now());
return value;
}
private static OutboundPalletItem pallet(Long warehousePalletId, String palletNo, String destinationCode,
String destinationName, String inboundCode, int packages) {
OutboundPalletItem value = new OutboundPalletItem();
value.setId(warehousePalletId + 1000); value.setCustomerId("TENANT-A"); value.setOutboundOrderId(301L);
value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo);
value.setDestinationWarehouseId("DEST-" + destinationCode);
value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName);
value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages);
return value;
}
}