fix(lastleg): complete receiving by container

This commit is contained in:
2026-08-24 17:56:43 +08:00
parent f4a1ec92de
commit 52e4b914b1
6 changed files with 106 additions and 18 deletions
@@ -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.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -33,6 +35,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@@ -49,6 +52,7 @@ public class InboundPdaWorkflowService {
@Resource private WarehouseResourceGateway warehouse; @Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts; @Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private LastlegOrderMapper orders;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles; @Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private ObjectMapper sourceObjectMapper; @Resource private ObjectMapper sourceObjectMapper;
@@ -509,11 +513,29 @@ public class InboundPdaWorkflowService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (CompletionContext context : contexts) completePlan(customerId, context, now); for (CompletionContext context : contexts) completePlan(customerId, context, now);
CompletionContext eventSource = contexts.get(0); String sourceContainerBookingId = sourceContainerBookingId(contexts);
unloadCompletedOutbox.publish(eventSource.plan(), eventSource.task(), eventSource.photoFileIds()); 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(); return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
} }
private String sourceContainerBookingId(List<CompletionContext> contexts) {
List<String> 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) { private CompletionContext completionContext(String customerId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId()); InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing"); if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing");
@@ -206,20 +206,20 @@ public class ReceivingContainerService {
InboundPlanPallet pallet = choosePallet(ref, resource); InboundPlanPallet pallet = choosePallet(ref, resource);
workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount());
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
final int accepted;
try { try {
accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet,
request.packageCount(), operatorKey, operatorName); 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) { } catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure); workflow.compensateReservation(customerId, resourceLockId, failure);
throw 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 @Transactional
@@ -245,16 +245,17 @@ public class ReceivingContainerService {
try { try {
workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton(
request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); 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) { } catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure); workflow.compensateReservation(customerId, resourceLockId, failure);
throw 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 @Transactional
@@ -4,15 +4,18 @@ import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerControlle
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import jakarta.validation.constraints.Size;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.RecordComponent; import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
class ReceivingContainerContractTest { class ReceivingContainerContractTest {
@@ -27,6 +30,13 @@ class ReceivingContainerContractTest {
.map(RecordComponent::getName).collect(Collectors.toSet()); .map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName()); 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 @Test
@@ -54,6 +64,10 @@ class ReceivingContainerContractTest {
assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_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_container_operation"));
assertTrue(schema.contains("wait_sign、signed、receiving、completed")); 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 @Test
@@ -65,6 +79,13 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
assertTrue(yaml.contains(prefix + "/current/complete:")); assertTrue(yaml.contains(prefix + "/current/complete:"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); 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<String> 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 { private String resource(String name) throws IOException {
@@ -79,4 +100,5 @@ class ReceivingContainerContractTest {
int end = schema.indexOf(") engine=innodb", start); int end = schema.indexOf(") engine=innodb", start);
return start < 0 || end < 0 ? "" : schema.substring(start, end); return start < 0 || end < 0 ? "" : schema.substring(start, end);
} }
} }
@@ -5,6 +5,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; 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 com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -15,9 +17,11 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List; import java.util.List;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -28,6 +32,7 @@ class InboundPdaContainerCompletionTest {
@Mock PdaWorkflowRepository repository; @Mock PdaWorkflowRepository repository;
@Mock InboundStockReceiptService stockReceipts; @Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService outbox; @Mock InboundUnloadCompletedOutboxService outbox;
@Mock LastlegOrderMapper orders;
private InboundPdaWorkflowService service; private InboundPdaWorkflowService service;
@BeforeEach @BeforeEach
@@ -36,6 +41,7 @@ class InboundPdaContainerCompletionTest {
ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
} }
@@ -53,12 +59,16 @@ class InboundPdaContainerCompletionTest {
when(repository.allocations(2L)).thenReturn(List.of(allocation(202L))); when(repository.allocations(2L)).thenReturn(List.of(allocation(202L)));
when(repository.cartons(any())).thenReturn(List.of()); when(repository.cartons(any())).thenReturn(List.of());
when(repository.pallets(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(second, first));
service.completeContainer("100", List.of(first, second)); service.completeContainer("100", List.of(first, second));
verify(stockReceipts, times(2)).stockIn(eq("100"), any(), any(), any(), any(), any(), any()); 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(InboundPlan.class));
verify(repository, times(2)).update(any(InboundPdaTask.class)); verify(repository, times(2)).update(any(InboundPdaTask.class));
} }
@@ -66,6 +76,7 @@ class InboundPdaContainerCompletionTest {
private static InboundPlan plan(long id, String planNo) { private static InboundPlan plan(long id, String planNo) {
InboundPlan value = new InboundPlan(); InboundPlan value = new InboundPlan();
value.setId(id); value.setPlanNo(planNo); value.setCustomerId("100"); value.setContainerNo("CONT-1"); value.setId(id); value.setPlanNo(planNo); value.setCustomerId("100"); value.setContainerNo("CONT-1");
value.setSourceOrderId(id == 1L ? 101L : 102L);
value.setStatus("RECEIVING"); value.setStatus("RECEIVING");
return value; return value;
} }
@@ -33,6 +33,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -96,6 +97,21 @@ class ReceivingContainerServiceBehaviorTest {
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .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 @Test
void destinationModesAreHardRejectedBeforeWarehouseCalls() { void destinationModesAreHardRejectedBeforeWarehouseCalls() {
stubCurrent("OVERSEAS", "CONT-A"); stubCurrent("OVERSEAS", "CONT-A");
@@ -138,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest {
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet())); when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) 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", assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key"))) new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key")))
.isInstanceOf(ResponseStatusException.class); .isInstanceOf(ResponseStatusException.class);
@@ -168,6 +184,21 @@ class ReceivingContainerServiceBehaviorTest {
verify(workflow, never()).completeContainer(any(), any()); 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) { private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo); InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType); InboundPlanLine line = line(destinationType);
@@ -71,6 +71,7 @@ class TaillegRealConsumerAcceptanceTest {
} }
public long reserve(String customerId, long warehouseId, long palletId, long locationId, public long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey) { return 1L; } String businessType, String businessId, String reservationKey) { return 1L; }
public void release(String customerId, long resourceLockId) { }
public void scrap(String customerId, long resourceLockId) { } public void scrap(String customerId, long resourceLockId) { }
public void scrapPallet(String customerId, long palletId) { } public void scrapPallet(String customerId, long palletId) { }
public WarehousePalletReference resolvePallet(String palletCode) { public WarehousePalletReference resolvePallet(String palletCode) {