feat: gate overseas receiving and harden phase4 workflows

This commit is contained in:
2026-08-25 10:31:04 +08:00
parent cb78bc6b41
commit 52b9fc382f
93 changed files with 2779 additions and 201 deletions
@@ -18,7 +18,11 @@ class UsTruckOrderSchemaContractTest {
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("allocated_pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)"));
assertTrue(sql.contains("operation_type VARCHAR(64) NOT NULL"));
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
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)"));
}
@@ -37,7 +37,7 @@ class UsTruckOrderControllerTest {
@Test
void platformStatusChangeRequiresManagePermission() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null);
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request));
assertEquals(403, error.getStatusCode().value());
@@ -45,11 +45,34 @@ class UsTruckOrderControllerTest {
@Test
void platformStatusChangeUsesExplicitTargetTenant() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned");
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", "cancelled");
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
verify(service).changeStatus("100", "TK1", "7", request);
}
@Test
void platformLifecycleCommandsUseExplicitTargetAndStatusPermission() {
var user = user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION));
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", null);
var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE", "overage-1", null);
var allocation = new UsTruckOrderRequests.AllocatePallets(3L, java.util.List.of(1L), "allocate-1", null);
var command = new UsTruckOrderRequests.Command(4L, "command-1", null);
platform.review(user, "100", "TK1", review);
platform.reviewPalletOverage(user, "100", "TK1", overage);
platform.allocatePallets(user, "100", "TK1", allocation);
platform.prepareDispatch(user, "100", "TK1", command);
platform.dispatch(user, "100", "TK1", command);
platform.startTransit(user, "100", "TK1", command);
platform.complete(user, "100", "TK1", command);
verify(service).review("100", "TK1", "7", review);
verify(service).reviewPalletOverage("100", "TK1", "7", overage);
verify(service).allocatePallets("100", "TK1", "7", allocation);
verify(service).prepareDispatch("100", "TK1", "7", command);
verify(service).dispatch("100", "TK1", "7", command);
verify(service).startTransit("100", "TK1", "7", command);
verify(service).complete("100", "TK1", "7", command);
}
@Test
void platformCreateUsesExplicitCustomerAndCreatePermission() {
var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null,
@@ -2,9 +2,11 @@ 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.UsTruckOrderOperation;
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.UsTruckOrderOperationMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -16,18 +18,18 @@ 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.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
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.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -35,7 +37,12 @@ class UsTruckOrderServiceTest {
@Mock UsTruckOrderMapper orders;
@Mock UsTruckOrderPalletMapper pallets;
@Mock UsTruckOrderStatusHistoryMapper histories;
@Mock UsTruckOrderOperationMapper operations;
private final List<UsTruckOrderPallet> palletRows = new ArrayList<>();
private final List<UsTruckOrderStatusHistory> historyRows = new ArrayList<>();
private final Map<String, UsTruckOrderOperation> operationRows = new HashMap<>();
private UsTruckOrderService service;
private UsTruckOrder order;
@BeforeEach
void setUp() {
@@ -43,113 +50,128 @@ class UsTruckOrderServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations);
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 staleOrdinaryUpdateIsRejectedAsConflict() {
when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.PENDING_REVIEW, 2L));
UsTruckOrderRequests.Update request = new UsTruckOrderRequests.Update(1L,
null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "TK1", "7", request));
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;
order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2);
when(orders.lockNo("100", "TK1")).thenAnswer(call -> order);
when(orders.findNo("100", "TK1")).thenAnswer(call -> order);
when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows);
when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows);
when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> {
historyRows.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));
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
UsTruckOrderPallet row = call.getArgument(0); palletRows.add(row); return 1;
});
when(operations.findKey(anyString(), anyLong(), anyString()))
.thenAnswer(call -> operationRows.get(call.getArgument(2)));
when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> {
UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1;
});
UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory();
created.setOperationType("CREATE"); created.setOperatorId("creator"); created.setIdempotencyKey("create-1");
created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created);
}
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);
@Test
void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() {
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve");
var reviewed = service.review("100", "TK1", "operator-7", review);
assertEquals(UsTruckOrderService.WAITING_INBOUND, reviewed.summary().status());
assertEquals(2, palletRows.size());
assertEquals(1L, reviewed.summary().version());
assertEquals(1L, service.review("100", "TK1", "operator-7", review).summary().version());
assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator-7",
new UsTruckOrderRequests.Review(1L, "REJECT", "review-1", "different")));
var receive = new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2),
"receive-1", "receive overage");
var received = service.receivePallets("100", "TK1", "receiver-8", receive);
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, received.summary().status());
assertEquals(3, received.summary().receivedPalletCount());
assertEquals(3, palletRows.size());
assertEquals(2L, service.receivePallets("100", "TK1", "receiver-8", receive).summary().version());
var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE",
"overage-1", "approve overage");
var overageReviewed = service.reviewPalletOverage("100", "TK1", "reviewer-9", overage);
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, overageReviewed.summary().status());
assertEquals(3, overageReviewed.summary().palletCount());
assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus())));
Long first = palletRows.get(0).getId();
List<Long> remaining = palletRows.subList(1, 3).stream().map(UsTruckOrderPallet::getId).toList();
var partial = service.allocatePallets("100", "TK1", "allocator-10",
new UsTruckOrderRequests.AllocatePallets(3L, List.of(first), "allocate-1", "partial"));
assertEquals(UsTruckOrderService.PARTIALLY_ALLOCATED, partial.summary().status());
assertEquals(1, partial.summary().allocatedPalletCount());
var allocated = service.allocatePallets("100", "TK1", "allocator-10",
new UsTruckOrderRequests.AllocatePallets(4L, remaining, "allocate-2", "all"));
assertEquals(UsTruckOrderService.ALLOCATED, allocated.summary().status());
assertEquals(3, allocated.summary().allocatedPalletCount());
assertEquals(UsTruckOrderService.PENDING_DISPATCH, service.prepareDispatch("100", "TK1", "dispatcher-11",
command(5L, "prepare-1")).summary().status());
assertEquals(UsTruckOrderService.DISPATCHED, service.dispatch("100", "TK1", "dispatcher-11",
command(6L, "dispatch-1")).summary().status());
assertEquals(UsTruckOrderService.IN_TRANSIT, service.startTransit("100", "TK1", "driver-12",
command(7L, "transit-1")).summary().status());
var completed = service.complete("100", "TK1", "driver-12", command(8L, "complete-1"));
assertEquals(UsTruckOrderService.COMPLETED, completed.summary().status());
assertEquals(9L, completed.summary().version());
assertEquals(9L, service.complete("100", "TK1", "driver-12", command(8L, "complete-1"))
.summary().version());
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7",
new UsTruckOrderRequests.ChangeStatus(9L, "CANCELLED", "cancel-terminal", "forbidden")));
assertThrows(ResponseStatusException.class, () -> service.startTransit("100", "TK1", "driver-12",
command(8L, "reverse-state")));
assertThrows(ResponseStatusException.class, () -> service.dispatch("100", "TK1", "dispatcher-11",
command(1L, "stale-version")));
assertEquals(List.of("CREATE", "REVIEW", "RECEIVE_PALLETS", "REVIEW_PALLET_OVERAGE",
"ALLOCATE_PALLETS", "ALLOCATE_PALLETS", "PREPARE_DISPATCH", "DISPATCH",
"START_TRANSIT", "COMPLETE"),
historyRows.stream().map(UsTruckOrderStatusHistory::getOperationType).toList());
assertTrue(historyRows.stream().skip(1).allMatch(row -> row.getOperatorId() != null
&& row.getIdempotencyKey() != null));
}
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;
@Test
void partialReceiptUsesReceivingThenExactReceiptMovesToConsolidation() {
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-partial", null));
var partial = service.receivePallets("100", "TK1", "receiver",
new UsTruckOrderRequests.ReceivePallets(1L, 1, 2, List.of(2), "receive-partial", null));
assertEquals(UsTruckOrderService.RECEIVING, partial.summary().status());
var exact = service.receivePallets("100", "TK1", "receiver",
new UsTruckOrderRequests.ReceivePallets(2L, 2, 4, List.of(2, 2), "receive-exact", null));
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, exact.summary().status());
}
@Test
void rejectedOverageExcludesExtraPalletFromAcceptedCount() {
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-reject-overage", null));
service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(
1L, 3, 6, List.of(2, 2, 2), "receive-reject-overage", null));
var reviewed = service.reviewPalletOverage("100", "TK1", "reviewer",
new UsTruckOrderRequests.ReviewPalletOverage(2L, "REJECT", "reject-overage", null));
assertEquals(2, reviewed.summary().receivedPalletCount());
assertEquals("OVERAGE_REJECTED", palletRows.get(2).getStatus());
}
private UsTruckOrderRequests.Command command(long version, String key) {
return new UsTruckOrderRequests.Command(version, key, key);
}
private UsTruckOrder order(String status, long version, int pallets) {
UsTruckOrder value = new UsTruckOrder();
value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1");
value.setTransportType("PORT_PICKUP"); value.setOrigin("Los Angeles"); value.setDestination("Ontario");
value.setPalletCount(pallets); value.setPackageCount(0); value.setReceivedPalletCount(0);
value.setAllocatedPalletCount(0); value.setStatus(status); value.setVersion(version);
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt());
return value;
}
}