This commit is contained in:
2026-08-24 17:51:11 +08:00
parent 4893542ec4
commit f4a1ec92de
3 changed files with 449 additions and 0 deletions
@@ -441,6 +441,133 @@ components:
cartonMark: { type: string, minLength: 1 }
palletCode: { type: string, minLength: 1 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingApiResponseBase:
type: object
required: [code, message, timestamp]
properties:
code: { type: string, example: '00000' }
message: { type: string }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
timestamp: { type: string, format: date-time }
ReceivingInbound:
type: object
required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages]
properties:
inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
fbaWarehouseCode: { type: string, nullable: true }
plannedPalletCount: { type: integer }
totalPackages: { type: integer }
receivedPackages: { type: integer }
pendingPackages: { type: integer }
ReceivingCandidate:
type: object
required: [containerNo, status, planCount, inboundCodeCount, totalPackages, receivedPackages, currentAccount]
properties:
containerNo: { type: string }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
planCount: { type: integer }
inboundCodeCount: { type: integer }
totalPackages: { type: integer }
receivedPackages: { type: integer }
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, status, version, photoFileIds, totalPackages, receivedPackages, pendingPackages, inbounds]
properties:
containerNo: { type: string }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
operatorName: { type: string, nullable: true }
version: { type: integer }
photoFileIds: { type: array, items: { type: integer, format: int64 } }
preparedAt: { type: string, format: date-time, nullable: true }
signedAt: { type: string, format: date-time, nullable: true }
receivingAt: { type: string, format: date-time, nullable: true }
completedAt: { type: string, format: date-time, nullable: true }
totalPackages: { type: integer }
receivedPackages: { type: integer }
pendingPackages: { type: integer }
inbounds: { type: array, items: { $ref: '#/components/schemas/ReceivingInbound' } }
ReceivingPhoto:
type: object
required: [fileId, fileName, contentType, sizeBytes]
properties:
fileId: { type: integer, format: int64 }
fileName: { type: string }
contentType: { type: string }
sizeBytes: { type: integer, format: int64 }
ReceivingCartonResolved:
allOf:
- { $ref: '#/components/schemas/ReceivingInbound' }
- type: object
required: [cartonMark]
properties:
cartonMark: { type: string }
skuCode: { type: string, nullable: true }
ReceivingPalletResolved:
type: object
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible]
properties:
palletCode: { type: string }
warehouseId: { type: integer, format: int64 }
status: { type: string }
fbaWarehouseCode: { type: string, nullable: true }
inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] }
compatible: { type: boolean }
ReceivingReceipt:
type: object
required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus]
properties:
result: { type: string }
containerNo: { type: string }
inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] }
palletCode: { type: string }
acceptedPackages: { type: integer }
receivedPackages: { type: integer }
pendingPackages: { type: integer }
sessionStatus: { type: string, enum: [SIGNED, RECEIVING] }
ReceivingCandidateListApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } }
ReceivingSessionApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingSession' } }
ReceivingPhotoApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingPhoto' } }
ReceivingCartonResolvedApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } }
ReceivingPalletResolvedApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingPalletResolved' } }
ReceivingReceiptApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingReceipt' } }
SmartDispatchCandidate:
type: object
properties:
@@ -0,0 +1,94 @@
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.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
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 InboundPdaContainerCompletionTest {
@Mock PdaWorkflowRepository repository;
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService outbox;
private InboundPdaWorkflowService service;
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void twoPlansOfOneContainerStockInTogetherAndPublishOneContainerEvent() {
InboundPlan first = plan(1L, "PLAN-1");
InboundPlan second = plan(2L, "PLAN-2");
InboundPdaTask firstTask = task(11L, 1L);
InboundPdaTask secondTask = task(22L, 2L);
when(repository.lockTask("100", 1L)).thenReturn(firstTask);
when(repository.lockTask("100", 2L)).thenReturn(secondTask);
when(repository.lines(1L)).thenReturn(List.of(line(101L)));
when(repository.lines(2L)).thenReturn(List.of(line(202L)));
when(repository.allocations(1L)).thenReturn(List.of(allocation(101L)));
when(repository.allocations(2L)).thenReturn(List.of(allocation(202L)));
when(repository.cartons(any())).thenReturn(List.of());
when(repository.pallets(any())).thenReturn(List.of());
service.completeContainer("100", List.of(second, first));
service.completeContainer("100", List.of(first, second));
verify(stockReceipts, times(2)).stockIn(eq("100"), any(), any(), any(), any(), any(), any());
verify(outbox, times(1)).publish(eq(first), eq(firstTask), eq(List.of(10L)));
verify(repository, times(2)).update(any(InboundPlan.class));
verify(repository, times(2)).update(any(InboundPdaTask.class));
}
private static InboundPlan plan(long id, String planNo) {
InboundPlan value = new InboundPlan();
value.setId(id); value.setPlanNo(planNo); value.setCustomerId("100"); value.setContainerNo("CONT-1");
value.setStatus("RECEIVING");
return value;
}
private static InboundPdaTask task(long id, long planId) {
InboundPdaTask value = new InboundPdaTask();
LocalDateTime now = LocalDateTime.of(2026, 8, 24, 10, 0);
value.setId(id); value.setPlanId(planId); value.setCustomerId("100"); value.setTaskNo("TASK-" + id);
value.setStatus("RECEIVING"); value.setUnloadPhotoFileIds("[10]"); value.setUnloadPhotoUploadedAt(now);
value.setEstimatedEmptyReturnAt(now.plusDays(2)); value.setVersion(1);
return value;
}
private static InboundPlanLine line(long id) {
InboundPlanLine value = new InboundPlanLine();
value.setId(id); value.setRequiredPackageCount(1); value.setDestinationType("FBA");
return value;
}
private static InboundPlanPalletAllocation allocation(long lineId) {
InboundPlanPalletAllocation value = new InboundPlanPalletAllocation();
value.setLineId(lineId); value.setPickedPackageCount(1); value.setCountedPackageCount(1);
value.setActualVolumeCbm(BigDecimal.ONE);
return value;
}
}
@@ -0,0 +1,228 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
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.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
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.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.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReceivingContainerServiceBehaviorTest {
@Mock InboundPlanMapper plans;
@Mock InboundPlanLineMapper lines;
@Mock InboundPlanPalletMapper pallets;
@Mock InboundPlanPalletAllocationMapper allocations;
@Mock InboundPlanCartonMapper cartons;
@Mock LastlegOrderMapper orders;
@Mock ReceivingContainerSessionMapper sessions;
@Mock ReceivingContainerOperationMapper operations;
@Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos;
@Mock InboundPdaWorkflowService workflow;
private ReceivingContainerService service;
@BeforeEach
void setUp() {
service = new ReceivingContainerService();
ReflectionTestUtils.setField(service, "plans", plans);
ReflectionTestUtils.setField(service, "lines", lines);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "allocations", allocations);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "sessions", sessions);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "workflow", workflow);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
}
@Test
void prepareIdempotencyHashIncludesContainerPath() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.insert(any())).thenReturn(1);
when(operations.insert(any())).thenReturn(1);
service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
verify(operations).insert(saved.capture());
when(operations.find("100", "7", "same-key")).thenReturn(saved.getValue());
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409");
}
@Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("100", "7", "CONT-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
stubCurrent("OVERSEAS", "CONT-A");
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "bulk-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse);
stubCurrent("FBA", "CONT-A");
InboundPlanCarton carton = carton(1L, "BOX-1");
when(cartons.selectOne(any())).thenReturn(carton);
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
}
@Test
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X"));
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("FBA", "CONT-A");
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
stubCurrent("FBA", "CONT-A");
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), any(Integer.class));
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1"));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void containerCompletionRequiresEveryInboundCodeToBalanceExactly() {
stubCurrent("FBA", "CONT-A");
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
partial.setLineId(10L); partial.setPickedPackageCount(1);
when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("100", "7", new ReceivingContainerRequests.Action("complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any(), any());
}
private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType);
LastlegOrder order = order(destinationType);
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session(containerNo, "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(line));
when(orders.selectById(1L)).thenReturn(order);
}
private static InboundPlan plan(String containerNo) {
InboundPlan value = new InboundPlan();
value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo);
value.setStatus("SIGNED");
return value;
}
private static InboundPlanLine line(String destinationType) {
InboundPlanLine value = new InboundPlanLine();
value.setId(10L); value.setCustomerId("100"); value.setPlanId(2L); value.setOrderId(1L);
value.setInboundCode("IN-1"); value.setDestinationType(destinationType); value.setRequiredPackageCount(2);
value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100");
value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
private static LastlegOrder order(String destinationType) {
LastlegOrder value = new LastlegOrder();
value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType);
value.setOverseasWarehouseId("100"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
return value;
}
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setStatus(status); value.setVersion(0);
return value;
}
private static InboundPlanCarton carton(long orderId, String cartonMark) {
InboundPlanCarton value = new InboundPlanCarton();
value.setOrderId(orderId); value.setCartonMark(cartonMark); value.setStatus("UNASSIGNED");
return value;
}
private static InboundPlanPallet planPallet() {
InboundPlanPallet value = new InboundPlanPallet();
value.setId(20L); value.setCustomerId("100"); value.setPlanId(2L);
value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100");
return value;
}
private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
"AVAILABLE", fbaCode, 0);
}
}