Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
This commit is contained in:
2026-09-09 00:41:47 +08:00
15 changed files with 626 additions and 271 deletions
@@ -66,6 +66,22 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
@Test
void resolveReservedAcceptsOnlyExactOccupiedPlanBinding() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED",
"warehouseId":21,"locationId":41,"status":"OCCUPIED","version":3,
"reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
var result = gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L);
assertThat(result.palletId()).isEqualTo(31L);
assertThat(result.status()).isEqualTo("OCCUPIED");
server.verify();
}
@Test
void inspectAllowsOccupiedPalletForReadOnlyInventoryQueries() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-OCCUPIED"))
@@ -84,6 +100,22 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
@Test
void resolveReservedRejectsDifferentPhysicalBinding() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED",
"warehouseId":21,"locationId":99,"status":"OCCUPIED","version":3,
"reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409")
.hasMessageContaining("does not match");
server.verify();
}
@Test
void resolveFailsClosedWhenWarehouseOmitsReservationFacts() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN"))
@@ -188,6 +188,98 @@ class InboundPdaWorkflowServiceTest {
verify(repository).update(targetPallet);
}
@Test
void reservedPalletScanConfirmsOnSiteWithoutReservingAgainAndAdvancesPlan() {
plan.setStatus("RESERVED");
targetPallet.setPalletNo("IP-ACY2-20260909-P001");
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
targetPallet.setLockedAt(null);
WarehouseResourceGateway.WarehousePalletReference resource =
new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L,
"OCCUPIED", "ACY2", 1);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assignResolvedPallet("100", plan, targetPallet, resource);
service.assignResolvedPallet("100", plan, targetPallet, resource);
assertEquals("A002", targetPallet.getPalletNo());
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
org.junit.jupiter.api.Assertions.assertNotNull(targetPallet.getLockedAt());
assertEquals("ASSIGNED", plan.getStatus());
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any());
verify(repository, times(1)).update(targetPallet);
verify(repository, times(1)).update(plan);
}
@Test
void publicAssignmentAlsoConfirmsReservedPalletWithoutReservingAgain() {
plan.setStatus("PENDING_ASSIGNMENT");
targetPallet.setDestinationWarehouseId("88");
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
targetPallet.setLockedAt(null);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, 188L, 288L, "reserved-public"));
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, 188L, 288L, "reserved-public-retry"));
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
assertEquals("ASSIGNED", plan.getStatus());
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any());
verify(repository, times(1)).update(targetPallet);
verify(repository, times(1)).update(plan);
}
@Test
void reservedConfirmationDoesNotRegressAdvancedPlanStatus() {
plan.setStatus("SIGNED");
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
WarehouseResourceGateway.WarehousePalletReference resource =
new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L,
"OCCUPIED", "ACY2", 1);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assignResolvedPallet("100", plan, targetPallet, resource);
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
assertEquals("SIGNED", plan.getStatus());
verify(repository, never()).update(plan);
}
@Test
void reservedPalletScanRejectsAnotherPhysicalPalletOrLocation() {
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
WarehouseResourceGateway.WarehousePalletReference anotherPallet =
new WarehouseResourceGateway.WarehousePalletReference(189L, "A003", 88L, 288L,
"IDLE", "ACY2", 1);
WarehouseResourceGateway.WarehousePalletReference anotherLocation =
new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 289L,
"IDLE", "ACY2", 1);
assertThrows(ResponseStatusException.class,
() -> service.assignResolvedPallet("100", plan, targetPallet, anotherPallet));
assertThrows(ResponseStatusException.class,
() -> service.assignResolvedPallet("100", plan, targetPallet, anotherLocation));
assertEquals("RESERVED", targetPallet.getAssignmentStatus());
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any());
verify(repository, never()).update(targetPallet);
}
@Test
void resolvedAssignmentRejectsPhysicalPalletWithoutBusinessCodeBeforeReservation() {
targetPallet.setWarehouseResourceLockId(null);
@@ -465,6 +465,37 @@ class ReceivingContainerServiceBehaviorTest {
verify(workflow).compensateReservation("100", 55L, failure);
}
@Test
void bulkReceivingResolvesExactPreReservedPalletWithoutIdleLookup() {
stubCurrent("FBA", "CONT-A");
stubSku();
InboundPlanPallet reserved = planPallet();
reserved.setPalletNo("PAL-RESERVED");
reserved.setWarehousePalletId(30L);
reserved.setWarehouseLocationId(40L);
reserved.setWarehouseResourceLockId(50L);
reserved.setAssignmentStatus("RESERVED");
when(pallets.selectList(any())).thenReturn(List.of(reserved));
WarehouseResourceGateway.WarehousePalletReference occupied =
new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-RESERVED", 100L, 40L,
"OCCUPIED", "FBA-1", 0);
when(warehouse.resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L))
.thenReturn(occupied);
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-RESERVED", 1, "reserved-key"));
verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L);
verify(warehouse, never()).resolvePallet("PAL-RESERVED");
verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"),
any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved),
org.mockito.ArgumentMatchers.same(occupied));
}
@Test
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
@@ -1,9 +1,11 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,8 +13,12 @@ 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.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
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;
@@ -20,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
@ExtendWith(MockitoExtension.class)
class ContainerInboundPlanConversionCoordinatorTest {
@@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
@Mock InboundPlanService plans;
@Mock WarehouseResourceGateway warehouse;
@Mock ContainerPlanConversionCommandMapper commands;
@Mock LastlegOrderNoGenerator numbers;
@Mock PlatformTransactionManager transactionManager;
private ContainerInboundPlanConversionCoordinator coordinator;
private JsonMapper objectMapper;
@@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
ReflectionTestUtils.setField(coordinator, "plans", plans);
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse);
ReflectionTestUtils.setField(coordinator, "commands", commands);
ReflectionTestUtils.setField(coordinator, "numbers", numbers);
ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper);
ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager);
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any()))
.thenReturn(new SimpleTransactionStatus());
}
@Test
@@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest {
verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class));
}
@Test
void sameIdempotencyKeyStillRejectsAlteredCompletedRequest() throws Exception {
ContainerPlanConversionCommand completed = command("FIRST-KEY", "COMPLETED", request());
completed.setResultJson("[]");
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(completed);
ConvertContainerInboundPlanRequest changed =
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64));
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(changed, "FIRST-KEY")).getStatusCode().value());
}
@Test
void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception {
ConvertContainerInboundPlanRequest request = request();
@@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null);
when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared);
when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"),
org.mockito.ArgumentMatchers.eq("APPLYING"), org.mockito.ArgumentMatchers.any())).thenReturn(0);
org.mockito.ArgumentMatchers.eq("APPLYING"), any(String.class), org.mockito.ArgumentMatchers.any())).thenReturn(0);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(request, "SECOND-KEY"));
@@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest {
assertEquals(409, error.getStatusCode().value());
}
@Test
void staleApplyingLeaseIsReclaimedWithoutRepeatingPreparedClaim() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand stale = command("FIRST-KEY", "APPLYING", request);
stale.setUpdatedAt(LocalDateTime.now().minusMinutes(3));
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(stale);
when(commands.reclaimStale(eq(1L), eq(stale.getUpdatedAt()), any(String.class), any())).thenReturn(1);
when(plans.convertContainerReserved(BOOKING_ID, 7L, SNAPSHOT_HASH, List.of())).thenReturn(List.of());
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(commands, never()).claim(any(Long.class), any(String.class), any(String.class),
any(String.class), any());
}
@Test
void compensatedFailureCanBeReplacedByFreshSnapshotAndComplete() throws Exception {
ContainerPlanConversionCommand failed = command("OLD-KEY", "FAILED", request());
when(commands.find(BOOKING_ID, "NEW-KEY")).thenReturn(null);
when(commands.findByBooking(BOOKING_ID)).thenReturn(failed);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64)));
when(commands.replaceFailed(eq(1L), eq("NEW-KEY"), any(), eq(8L), eq("b".repeat(64)),
eq(31L), any(), any(), any())).thenReturn(1);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(plans.convertContainerReserved(BOOKING_ID, 8L, "b".repeat(64), List.of())).thenReturn(List.of());
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)), "NEW-KEY"));
}
@Test
void invalidEnsureResponseIsCompensatedBeforeCommandBecomesFailed() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
setOnePreparedItem(prepared);
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
0L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
when(warehouse.compensatePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "COMPENSATED", 1, false, List.of(reserved("RELEASED"))));
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("FAILED"),
org.mockito.ArgumentMatchers.isNull(), any(), any())).thenReturn(1);
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(request, "FIRST-KEY")).getStatusCode().value());
verify(warehouse).compensatePalletBatch(any());
}
@Test
void uncertainLocalCommitIsRereadBeforeDecidingWhetherToCompensate() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
setOnePreparedItem(prepared);
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
committed.setResultJson("[]");
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
when(plans.convertContainerReserved(eq(BOOKING_ID), eq(7L), eq(SNAPSHOT_HASH), any()))
.thenThrow(new IllegalStateException("commit outcome unknown"));
when(commands.findByBooking(BOOKING_ID)).thenReturn(committed);
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(warehouse, never()).compensatePalletBatch(any());
}
@Test
void localCommittedReplayOnlyRetriesRemoteConfirmation() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
committed.setResultJson("[]");
setOnePreparedItem(committed);
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed);
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(warehouse, never()).ensurePalletBatch(any());
verify(warehouse).confirmPalletBatch(any());
}
private ConvertContainerInboundPlanRequest request() {
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
}
@@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest {
command.setWarehouseId(31L);
command.setBatchKey("BATCH-1");
command.setStatus(status);
if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1");
command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of())));
return command;
}
private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception {
var item = new ContainerInboundPlanConversionCoordinator.PreparedItem(
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8");
command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item))));
}
private WarehouseResourceGateway.PalletBatchItem reserved(String status) {
return new WarehouseResourceGateway.PalletBatchItem(
100L, "CIP:1:1", "IP-21", 1, 51L, "PLT-51", 61L, 71L, status);
}
private ContainerInboundPlanPreviewResponse preview(long version, String hash) {
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of());
}
}
@@ -67,6 +67,21 @@ class ContainerInboundPlanPreviewTest {
assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers());
}
@Test
void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() {
when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
null, null, null)));
var result = service.previewContainer("BOOKING-NULL");
assertNotNull(result.snapshotHash());
assertEquals(64, result.snapshotHash().length());
assertFalse(result.ready());
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) {