diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f3de2ced2..17646d664 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java new file mode 100644 index 000000000..9bcf5cdde --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java @@ -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; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java new file mode 100644 index 000000000..78237ecbc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -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 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); + } +}