diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 9747c976f..4b3e93b8b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -2,7 +2,6 @@ package com.baiyue.lastleg.inboundplan.pda.dto; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -13,7 +12,7 @@ public final class ReceivingContainerRequests { private ReceivingContainerRequests() { } public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } - public record Sign(@Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, + public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, @NotBlank @Size(max = 255) String idempotencyKey) { } public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { } public record CartonResolve(@NotBlank String cartonMark) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 124ddec0d..7262e4677 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -533,6 +533,7 @@ public class ReceivingContainerService { .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId())); Set skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode) .map(ReceivingContainerService::trim).filter(Objects::nonNull) + .map(value -> value.toUpperCase(java.util.Locale.ROOT)) .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); int expected = 0; int received = 0; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index aa4e1ea67..5736a0949 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -63,6 +63,12 @@ class ReceivingContainerContractTest { assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); + assertTrue(schema.contains("sign_photos json")); + assertFalse(schema.contains("photo_file_ids json")); + assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(unified.contains("column_name='sign_photos'")); assertTrue(schema.contains("wait_sign、signed、receiving、completed")); assertTrue(unified.contains("customer_id is null or l.customer_id=''")); assertTrue(unified.contains("customer_id is null or v.customer_id=''")); @@ -76,16 +82,19 @@ class ReceivingContainerContractTest { String prefix = "/api/pda/v1/last-leg/receiving-containers"; assertTrue(yaml.contains(prefix + "/candidates:")); assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); + assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); assertTrue(yaml.contains(prefix + "/current/complete:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(11, successResponses.size()); + assertEquals(12, successResponses.size()); assertTrue(successResponses.stream().allMatch(line -> line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); + assertTrue(receivingPaths.contains("LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN")); + assertFalse(receivingPaths.contains("photoFileIds")); } private String resource(String name) throws IOException { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index d049e9171..c9c3d0fb7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -32,7 +32,7 @@ class ReceivingContainerControllerTest { @Test void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() { when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( - "CONT-1", "WAIT_SIGN", 2, 2, 20, 0, false))); + "CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false))); controller.candidates(pda()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 1e28bfc5b..7bde9af07 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -8,7 +8,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; @@ -45,6 +47,7 @@ class InboundStockReceiptServiceTest { @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper orderDetails; @Mock InboundStockReceiptMapper receipts; + @Mock ReceivingSkuReceiptMapper receivingSkuReceipts; @Mock InventoryCommandService inventory; @Mock DropshipOrderService dropshipOrders; @Mock LastlegOrderStatusHistoryMapper orderHistories; @@ -56,10 +59,12 @@ class InboundStockReceiptServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "receipts", receipts); + ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); when(receipts.listTask("100", 2L)).thenReturn(List.of()); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of()); } @Test @@ -108,6 +113,30 @@ class InboundStockReceiptServiceTest { verify(receipts, times(2)).insert(any(InboundStockReceipt.class)); } + @Test + void exactFbaSkuReceiptsPreserveSkuToPalletAttribution() { + Fixture fixture = fixture(10, 5, 5, 10); + fixture.detail.setPackageCount(5); + LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7); + when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, second)); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + ReceivingSkuReceipt first = skuReceipt(12L, 21L, "SKU-1", 5); + ReceivingSkuReceipt secondReceipt = skuReceipt(12L, 22L, "SKU-2", 5); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(first, secondReceipt)); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + + ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); + assertEquals(List.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream() + .map(value -> value.skuCode() + ":" + value.warehousePalletId()).toList()); + } + @Test void conservesSingleSkuPackagesAndQuantityAcrossPallets() { Fixture fixture = fixture(5, 2, 2, 11); @@ -216,6 +245,12 @@ class InboundStockReceiptServiceTest { allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation; } + private ReceivingSkuReceipt skuReceipt(long lineId, long palletId, String skuCode, int packages) { + ReceivingSkuReceipt receipt = new ReceivingSkuReceipt(); receipt.setLineId(lineId); receipt.setPalletId(palletId); + receipt.setSkuCode(skuCode); receipt.setProductName(skuCode + " name"); receipt.setReceivedPackages(packages); + return receipt; + } + private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order, LastlegOrderDetail detail, InboundPlanPalletAllocation allocation, InboundPlanPallet pallet) {} 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 index c25531ae2..a66a742d8 100644 --- 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 @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; @@ -12,12 +13,15 @@ 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.dto.ReceivingPhotoRole; 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.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -48,8 +52,10 @@ class ReceivingContainerServiceBehaviorTest { @Mock InboundPlanPalletAllocationMapper allocations; @Mock InboundPlanCartonMapper cartons; @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper orderDetails; @Mock ReceivingContainerSessionMapper sessions; @Mock ReceivingContainerOperationMapper operations; + @Mock ReceivingSkuReceiptMapper skuReceipts; @Mock WarehouseResourceGateway warehouse; @Mock UnloadPhotoFileGateway unloadPhotos; @Mock InboundPdaWorkflowService workflow; @@ -64,8 +70,10 @@ class ReceivingContainerServiceBehaviorTest { ReflectionTestUtils.setField(service, "allocations", allocations); ReflectionTestUtils.setField(service, "cartons", cartons); ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "sessions", sessions); ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos); ReflectionTestUtils.setField(service, "workflow", workflow); @@ -116,7 +124,7 @@ class ReceivingContainerServiceBehaviorTest { void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "bulk-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -141,9 +149,10 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); stubCurrent("FBA", "CONT-A"); + stubSku(); 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"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); } @@ -151,12 +160,13 @@ class ReceivingContainerServiceBehaviorTest { @Test void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() { stubCurrent("FBA", "CONT-A"); + stubSku(); 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(), anyInt()); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 9, "over-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -176,6 +186,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void containerCompletionRequiresEveryInboundCodeToBalanceExactly() { stubCurrent("FBA", "CONT-A"); + stubSku(); InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation(); partial.setLineId(10L); partial.setPickedPackageCount(1); when(allocations.selectList(any())).thenReturn(List.of(partial)); @@ -194,11 +205,48 @@ class ReceivingContainerServiceBehaviorTest { doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "compensate-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key"))) .isSameAs(failure); verify(workflow).compensateReservation("100", 55L, failure); } + @Test + void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); + when(lines.selectList(any())).thenReturn(List.of()); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key"); + + var response = service.sign("100", "7", "receiver", request); + + org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role") + .containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL, + ReceivingPhotoRole.DOOR_OPEN); + verify(unloadPhotos).validate("100", 8L, 1L); + verify(unloadPhotos).validate("100", 8L, 2L); + verify(unloadPhotos).validate("100", 8L, 3L); + } + + @Test + void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { + stubCurrent("FBA", "CONT-A"); + stubSku(); + com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received = + new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt(); + received.setReceivedPackages(2); + when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received)); + + assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(warehouse); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); @@ -209,6 +257,11 @@ class ReceivingContainerServiceBehaviorTest { when(orders.selectById(1L)).thenReturn(order); } + private void stubSku() { + when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-1", 2))); + when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of()); + } + private static InboundPlan plan(String containerNo) { InboundPlan value = new InboundPlan(); value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo); @@ -232,6 +285,13 @@ class ReceivingContainerServiceBehaviorTest { return value; } + private static LastlegOrderDetail detail(String skuCode, int packages) { + LastlegOrderDetail value = new LastlegOrderDetail(); + value.setId(11L); value.setCustomerId("100"); value.setOrderId(1L); + value.setSkuCode(skuCode); value.setProductNameZh("产品一"); value.setPackageCount(packages); + 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");