feat: complete tail order controls and terminal drayage

This commit is contained in:
2026-09-07 23:58:18 +08:00
parent f018ca85a9
commit e32fe65fa8
53 changed files with 2566 additions and 87 deletions
@@ -0,0 +1,161 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import com.baiyue.lastleg.entity.LastlegOrderHoldHistory;
import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderCommandServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderCommandRecordMapper commands;
@Mock LastlegOrderHoldHistoryMapper holdHistory;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Mock LastlegOrderOperationLogMapper operationLogs;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
LastlegOrderCommandService service;
@BeforeEach
void setUp() {
service = new LastlegOrderCommandService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "holdHistory", holdHistory);
ReflectionTestUtils.setField(service, "manualNodes", manualNodes);
ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory);
ReflectionTestUtils.setField(service, "operationLogs", operationLogs);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
}
@Test
void holdsOnlySelectedOrderWithVersionAndImmutableHistory() {
LastlegOrder order = order(11L, 3);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3),
any(LocalDateTime.class))).thenReturn(1);
var response = service.hold("100", 11L, "hold-11",
new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 3), "900", "管理员");
assertTrue(response.held());
assertTrue(response.chargeable());
assertEquals(4, response.version());
verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any());
verify(holdHistory).insert(any(LastlegOrderHoldHistory.class));
verify(commands).insert(any(LastlegOrderCommandRecord.class));
verify(orders, never()).lockContainerOrders(any());
}
@Test
void rejectsStaleHoldWithoutAnyWrite() {
LastlegOrder order = order(11L, 4);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L,
"hold-stale", new LastlegOrderCommandDtos.HoldRequest(true, false, "待核对", 3), "900", "管理员"));
assertEquals(409, error.getStatusCode().value());
verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(),
nullable(String.class), anyInt(), any(LocalDateTime.class));
verify(commands, never()).insert(any(LastlegOrderCommandRecord.class));
}
@Test
void savesExactEightRowManualSnapshotInFrozenDisplayOrder() {
LastlegOrder order = order(12L, 0);
when(orders.selectById(12L)).thenReturn(order);
when(orders.lockById("100", 12L)).thenReturn(order);
when(orders.updateManualNodes(eq("100"), eq(12L), any(), any(), any(), eq(0), any())).thenReturn(1);
LocalDateTime filled = LocalDateTime.of(2026, 9, 8, 10, 0);
List<LastlegOrderCommandDtos.ManualNode> nodes = List.of(
node("UNLOADED", null), node("PICKUP_APPOINTED", null),
node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null), node("OVERSEAS_CUSTOMS_COMPLETED", null),
node("ESTIMATED_ARRIVAL", filled), node("ACTUAL_DEPARTURE", null),
node("ESTIMATED_DEPARTURE", null), node("DOMESTIC_CUSTOMS_COMPLETED", null));
var response = service.saveManualNodes("100", 12L, "nodes-12",
new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员");
assertTrue(response.manualOverrideEnabled());
assertEquals(8, response.nodes().size());
assertEquals("DOMESTIC_CUSTOMS_COMPLETED", response.nodes().get(0).nodeCode());
assertEquals("UNLOADED", response.nodes().get(7).nodeCode());
ArgumentCaptor<LastlegOrderManualNode> rows = ArgumentCaptor.forClass(LastlegOrderManualNode.class);
verify(manualNodes, org.mockito.Mockito.times(8)).insert(rows.capture());
assertEquals(8, rows.getAllValues().size());
verify(manualNodeHistory).insert(any(LastlegOrderManualNodeHistory.class));
}
@Test
void rejectsIncompleteOrFullyEmptyManualSnapshot() {
LastlegOrder order = order(12L, 0);
when(orders.selectById(12L)).thenReturn(order);
when(orders.lockById("100", 12L)).thenReturn(order);
List<LastlegOrderCommandDtos.ManualNode> nodes = List.of(
node("DOMESTIC_CUSTOMS_COMPLETED", null), node("ESTIMATED_DEPARTURE", null),
node("ACTUAL_DEPARTURE", null), node("ESTIMATED_ARRIVAL", null),
node("OVERSEAS_CUSTOMS_COMPLETED", null), node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null),
node("PICKUP_APPOINTED", null), node("UNLOADED", null));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.saveManualNodes("100", 12L, "nodes-empty",
new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员"));
assertEquals(400, error.getStatusCode().value());
verify(orders, never()).updateManualNodes(anyString(), anyLong(), nullable(LocalDateTime.class),
nullable(LocalDateTime.class), nullable(LocalDateTime.class), anyInt(), any(LocalDateTime.class));
}
private LastlegOrderCommandDtos.ManualNode node(String code, LocalDateTime time) {
return new LastlegOrderCommandDtos.ManualNode(code, time);
}
private LastlegOrder order(long id, int version) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
order.setCustomerId("100");
order.setOrderNo("LL-" + id);
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setDetained(false);
order.setVersion(version);
return order;
}
}
@@ -14,6 +14,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import org.junit.jupiter.api.BeforeEach;
@@ -54,6 +55,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderFeeMapper fees;
@Mock LastlegOrderOperationLogMapper operations;
@Mock LastlegOrderManualNodeMapper manualNodes;
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
@Mock InventoryCommandService inventory;
@@ -70,6 +72,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "feeMapper", fees);
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses);
lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation ->
@@ -133,6 +136,7 @@ class LastlegOrderServiceTest {
when(histories.selectList(any())).thenReturn(List.of());
when(fees.selectList(any())).thenReturn(List.of());
when(operations.selectList(any())).thenReturn(List.of());
when(manualNodes.selectList(any())).thenReturn(List.of());
var response = service.detail("100", 11L);