diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 9321d0511..dde1adef1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -14,6 +14,8 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,6 +35,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -49,6 +52,7 @@ public class InboundPdaWorkflowService { @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Resource private LastlegOrderMapper orders; @Resource private UnloadPhotoFileGateway unloadPhotoFiles; @Resource private ObjectMapper sourceObjectMapper; @@ -509,11 +513,29 @@ public class InboundPdaWorkflowService { LocalDateTime now = LocalDateTime.now(); for (CompletionContext context : contexts) completePlan(customerId, context, now); - CompletionContext eventSource = contexts.get(0); - unloadCompletedOutbox.publish(eventSource.plan(), eventSource.task(), eventSource.photoFileIds()); + String sourceContainerBookingId = sourceContainerBookingId(contexts); + LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) + .max(LocalDateTime::compareTo).orElseThrow(); + long aggregateVersion = contexts.stream().map(CompletionContext::task) + .map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L); + unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo, + now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); } + private String sourceContainerBookingId(List contexts) { + List bookingIds = contexts.stream().map(context -> { + LastlegOrder order = orders.selectById(context.plan().getSourceOrderId()); + if (order == null || order.getSourceContainerBookingId() == null + || order.getSourceContainerBookingId().isBlank()) { + throw new IllegalStateException("Inbound plan has no source container booking ID"); + } + return order.getSourceContainerBookingId(); + }).distinct().toList(); + if (bookingIds.size() != 1) throw conflict("Container plans reference different source container bookings"); + return bookingIds.get(0); + } + private CompletionContext completionContext(String customerId, InboundPlan plan) { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing"); 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 c34323c29..c7cdf8f7a 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 @@ -206,20 +206,20 @@ public class ReceivingContainerService { InboundPlanPallet pallet = choosePallet(ref, resource); workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); - final int accepted; try { - accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, + int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, request.packageCount(), operatorKey, operatorName); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); + return result; } catch (RuntimeException failure) { workflow.compensateReservation(customerId, resourceLockId, failure); throw failure; } - markReceiving(session); - ReceivingContainerResponses.Inbound inbound = inbound(ref); - ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", - session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, - inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); - save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result; } @Transactional @@ -245,16 +245,17 @@ public class ReceivingContainerService { try { workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); + return result; } catch (RuntimeException failure) { workflow.compensateReservation(customerId, resourceLockId, failure); throw failure; } - markReceiving(session); - ReceivingContainerResponses.Inbound inbound = inbound(ref); - ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", - session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, - inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); - save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); return result; } @Transactional 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 6d81dc32b..aa4e1ea67 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 @@ -4,15 +4,18 @@ import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerControlle import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import jakarta.validation.constraints.Size; import java.io.IOException; import java.lang.reflect.RecordComponent; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class ReceivingContainerContractTest { @@ -27,6 +30,13 @@ class ReceivingContainerContractTest { .map(RecordComponent::getName).collect(Collectors.toSet()); assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName()); } + for (Class type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class, + ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) { + RecordComponent key = Arrays.stream(type.getRecordComponents()) + .filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow(); + Size size = key.getAccessor().getAnnotation(Size.class); + assertTrue(size != null && size.max() == 255, type.getSimpleName()); + } } @Test @@ -54,6 +64,10 @@ class ReceivingContainerContractTest { 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("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=''")); + assertTrue(unified.contains("customer_id is null or a.customer_id=''")); + assertTrue(unified.contains("signal sqlstate '45000'")); } @Test @@ -65,6 +79,13 @@ class ReceivingContainerContractTest { 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()); + assertTrue(successResponses.stream().allMatch(line -> + line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); + assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); } private String resource(String name) throws IOException { @@ -79,4 +100,5 @@ class ReceivingContainerContractTest { int end = schema.indexOf(") engine=innodb", start); return start < 0 || end < 0 ? "" : schema.substring(start, end); } + } 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 index 9bcf5cdde..50844ce90 100644 --- 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 @@ -5,6 +5,8 @@ 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.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,9 +17,11 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -28,6 +32,7 @@ class InboundPdaContainerCompletionTest { @Mock PdaWorkflowRepository repository; @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService outbox; + @Mock LastlegOrderMapper orders; private InboundPdaWorkflowService service; @BeforeEach @@ -36,6 +41,7 @@ class InboundPdaContainerCompletionTest { ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); + ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); } @@ -53,12 +59,16 @@ class InboundPdaContainerCompletionTest { when(repository.allocations(2L)).thenReturn(List.of(allocation(202L))); when(repository.cartons(any())).thenReturn(List.of()); when(repository.pallets(any())).thenReturn(List.of()); + LastlegOrder source = new LastlegOrder(); source.setSourceContainerBookingId("BOOKING-1"); + when(orders.selectById(101L)).thenReturn(source); + when(orders.selectById(102L)).thenReturn(source); 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(outbox, times(1)).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"), + any(Instant.class), any(Instant.class), anyLong()); verify(repository, times(2)).update(any(InboundPlan.class)); verify(repository, times(2)).update(any(InboundPdaTask.class)); } @@ -66,6 +76,7 @@ class InboundPdaContainerCompletionTest { 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.setSourceOrderId(id == 1L ? 101L : 102L); value.setStatus("RECEIVING"); 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 index 78237ecbc..6fa1bee5b 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 @@ -33,6 +33,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -96,6 +97,21 @@ class ReceivingContainerServiceBehaviorTest { .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } + @Test + void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() { + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); + verify(sessions, never()).insert(any()); + + org.mockito.Mockito.reset(sessions, operations); + when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); + verify(sessions, never()).insert(any()); + } + @Test void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); @@ -138,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest { 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)); + .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key"))) .isInstanceOf(ResponseStatusException.class); @@ -168,6 +184,21 @@ class ReceivingContainerServiceBehaviorTest { verify(workflow, never()).completeContainer(any(), any()); } + @Test + void localFailureAfterRemoteReservationTriggersReleaseCompensation() { + stubCurrent("FBA", "CONT-A"); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); + when(pallets.selectList(any())).thenReturn(List.of(planPallet())); + when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L); + ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race"); + 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"))) + .isSameAs(failure); + verify(workflow).compensateReservation("100", 55L, failure); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 6099cda63..c3df43abc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -71,6 +71,7 @@ class TaillegRealConsumerAcceptanceTest { } public long reserve(String customerId, long warehouseId, long palletId, long locationId, String businessType, String businessId, String reservationKey) { return 1L; } + public void release(String customerId, long resourceLockId) { } public void scrap(String customerId, long resourceLockId) { } public void scrapPallet(String customerId, long palletId) { } public WarehousePalletReference resolvePallet(String palletCode) {