diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java
index 7c23de9c9..029056ada 100644
--- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java
+++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java
@@ -10,6 +10,8 @@ public final class EventTypes {
public static final String ORDER_CANCELLED = "OrderCancelled";
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
+ public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
+ "FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java
new file mode 100644
index 000000000..9d1dbf684
--- /dev/null
+++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java
@@ -0,0 +1,41 @@
+package com.baiyue.common.event;
+
+import java.time.Instant;
+
+/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
+public record FirstLegContainerShippingNodeRecordedV1(
+ String queryId,
+ String customerId,
+ String oceanBookingId,
+ String containerBookingId,
+ String containerNo,
+ String standardNode,
+ int nodeSequence,
+ Instant observedAt,
+ Instant actualAt,
+ String locationCode,
+ String locationName,
+ String providerCode,
+ String providerEventId,
+ String providerNodeCode,
+ String sourceReference,
+ String rawPayloadHash) {
+
+ public FirstLegContainerShippingNodeRecordedV1 {
+ if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
+ || oceanBookingId == null || oceanBookingId.isBlank()
+ || containerBookingId == null || containerBookingId.isBlank()
+ || containerNo == null || containerNo.isBlank()) {
+ throw new IllegalArgumentException("船节点交接业务标识不能为空");
+ }
+ if (standardNode == null || standardNode.isBlank() || nodeSequence < 10
+ || observedAt == null || actualAt == null) {
+ throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
+ }
+ if (providerCode == null || providerCode.isBlank() || providerEventId == null
+ || providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank()
+ || rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) {
+ throw new IllegalArgumentException("船节点交接供应商审计字段不完整");
+ }
+ }
+}
diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java
new file mode 100644
index 000000000..3f69c7f1f
--- /dev/null
+++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java
@@ -0,0 +1,43 @@
+package com.baiyue.common.event;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class FirstLegContainerShippingNodeRecordedV1Test {
+ @Test
+ void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception {
+ var payload = payload();
+ var mapper = new ObjectMapper().findAndRegisterModules();
+
+ var restored = mapper.readValue(mapper.writeValueAsString(payload),
+ FirstLegContainerShippingNodeRecordedV1.class);
+
+ assertThat(restored).isEqualTo(payload);
+ assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
+ assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
+ assertThat(restored.nodeSequence()).isEqualTo(80);
+ }
+
+ @Test
+ void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
+ assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
+ "1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
+ Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
+ "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
+ .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
+ }
+
+ private static FirstLegContainerShippingNodeRecordedV1 payload() {
+ return new FirstLegContainerShippingNodeRecordedV1(
+ "2088000000000000001", "2088000000000000002", "2088000000000000004",
+ "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
+ Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
+ "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
+ "ZIM:evt-1", "a".repeat(64));
+ }
+}
diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java
index 3a5a42502..de69a8909 100644
--- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java
+++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
.addOperationCustomizer(correlationHeaderCustomizer).build();
}
+ @Bean
+ GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
+ return GroupedOpenApi.builder().group("pda")
+ .displayName("PDA APIs / PDA 作业接口")
+ .pathsToMatch("/api/pda/**")
+ .addOperationCustomizer(correlationHeaderCustomizer).build();
+ }
+
private Parameter header(String name, String description, boolean required) {
return new HeaderParameter().name(name).description(description).required(required)
.schema(new io.swagger.v3.oas.models.media.StringSchema());
diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java
new file mode 100644
index 000000000..dcf28b7df
--- /dev/null
+++ b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java
@@ -0,0 +1,17 @@
+package com.baiyue.common.openapi;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PdaOpenApiGroupContractTest {
+ @Test
+ void pdaGroupOnlyMatchesDeviceApis() {
+ BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration();
+ var group = configuration.pdaApi(configuration.correlationHeaderCustomizer());
+
+ assertThat(group.getGroup()).isEqualTo("pda");
+ assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**");
+ assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal"));
+ }
+}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java
new file mode 100644
index 000000000..0869ea824
--- /dev/null
+++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java
@@ -0,0 +1,39 @@
+package com.baiyue.eventbridge.service;
+
+import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EventBridgeReliabilitySqlContractTest {
+ @Test
+ void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception {
+ String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class);
+ String claim = sql(BridgeInboxMapper.class.getMethod(
+ "claim", String.class, String.class, long.class), Update.class);
+ String failed = sql(BridgeInboxMapper.class.getMethod(
+ "markFailed", String.class, String.class, int.class, long.class, String.class), Update.class);
+ String processed = sql(BridgeInboxMapper.class.getMethod(
+ "markProcessed", String.class, String.class), Update.class);
+
+ assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)",
+ "status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at");
+ assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD",
+ "status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)");
+ assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'",
+ "next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}");
+ assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL",
+ "status='PROCESSING'", "lease_owner=#{owner}");
+ }
+
+ private String sql(Method method, Class type) {
+ A annotation = method.getAnnotation(type);
+ String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value();
+ return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " ");
+ }
+}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java
index 67cb7b3d1..01468d95a 100644
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java
+++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java
@@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.net.URI;
import java.time.Duration;
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
@@ -103,6 +104,59 @@ class OverseasEventDispatcherTest {
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
+ @Test
+ void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception {
+ for (String target : List.of("OVERSEAS", "CN")) {
+ reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient);
+ event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN");
+ event.setTargetRegion(target);
+ event.setRetryCount(0);
+ when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
+ when(inboxMapper.selectById("event-1")).thenReturn(event);
+ when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2);
+ doAnswer(invocation -> {
+ event.setRetryCount(1);
+ event.setStatus("RETRY");
+ event.setLastError(invocation.getArgument(4));
+ event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds(
+ ((Number) invocation.getArgument(3)).longValue()));
+ return 1;
+ }).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString());
+ when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> {
+ event.setStatus("PROCESSED");
+ event.setLastError(null);
+ event.setNextRetryAt(null);
+ return 1;
+ });
+ if ("CN".equals(target)) {
+ when(firstlegEventClient.deliver(event))
+ .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn"))
+ .thenReturn("200");
+ } else {
+ when(eventClient.deliver(event))
+ .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas"))
+ .thenReturn("200");
+ }
+
+ dispatcher.dispatchOne("event-1");
+ assertThat(event.getStatus()).isEqualTo("RETRY");
+ assertThat(event.getNextRetryAt()).isNotNull();
+ assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId=");
+ dispatcher.dispatchOne("event-1");
+
+ ArgumentCaptor attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
+ verify(attemptMapper, times(2)).updateById(attempts.capture());
+ assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo)
+ .containsExactly(1, 2);
+ assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus)
+ .containsExactly("RETRY", "DELIVERED");
+ assertThat(attempts.getAllValues().get(0).getErrorMessage())
+ .contains("HTTP 503", "requestId=", "traceId=");
+ assertThat(event.getStatus()).isEqualTo("PROCESSED");
+ assertThat(event.getLastError()).isNull();
+ }
+ }
+
private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture());
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java
new file mode 100644
index 000000000..5f949eece
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java
@@ -0,0 +1,75 @@
+package com.baiyue.firstleg.portschedule.service;
+
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.event.EventEnvelope;
+import com.baiyue.common.event.EventIdentifiers;
+import com.baiyue.common.event.EventPayloadCanonicalizer;
+import com.baiyue.common.event.EventPayloadHash;
+import com.baiyue.common.event.EventTypes;
+import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
+import com.baiyue.common.event.RegionCode;
+import com.baiyue.firstleg.entity.InboundOutboxEntity;
+import com.baiyue.firstleg.mapper.InboundOutboxMapper;
+import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
+import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+
+/** Writes actual carrier node facts to the domestic transactional Outbox. */
+@Service
+public class CarrierScheduleNodeOutboxService {
+ @Resource private InboundOutboxMapper outbox;
+ @Resource private ObjectMapper objectMapper;
+ @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
+ private String scheduleZone = "Asia/Shanghai";
+
+ public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact,
+ String containerNo) {
+ if (fact.getActualAt() == null) return;
+ ZoneId zone = ZoneId.of(scheduleZone);
+ var payload = new FirstLegContainerShippingNodeRecordedV1(
+ string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()),
+ string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(),
+ fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
+ fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
+ fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
+ JsonNode payloadNode = objectMapper.valueToTree(payload);
+ String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
+ long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion());
+ String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
+ EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
+ EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
+ fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
+ string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()),
+ aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
+ payloadNode, payloadHash).requireValid();
+
+ InboundOutboxEntity row = new InboundOutboxEntity();
+ row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId());
+ row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
+ row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
+ row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
+ row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
+ row.setCreatedAt(LocalDateTime.now());
+ try { outbox.insert(row); } catch (DuplicateKeyException ignored) { }
+ }
+
+ private String json(Object value) {
+ try { return objectMapper.writeValueAsString(value); }
+ catch (JsonProcessingException exception) {
+ throw new BizException(ResultCode.INTERNAL_ERROR, "船节点交接事件序列化失败");
+ }
+ }
+
+ private static String string(Long value) { return value == null ? null : String.valueOf(value); }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java
index cb8935edd..89d6f427f 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java
@@ -13,11 +13,14 @@ import java.util.List;
@Repository
public class LoadingCompletedOutboxRepository {
+ private static final List CROSS_REGION_EVENT_TYPES = List.of(
+ EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
+ EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
@Resource private InboundOutboxMapper mapper;
public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.selectList(new LambdaQueryWrapper()
- .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
+ .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
.or().le(InboundOutboxEntity::getNextRetryAt, now))
@@ -29,7 +32,7 @@ public class LoadingCompletedOutboxRepository {
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.update(null, new LambdaUpdateWrapper()
.eq(InboundOutboxEntity::getId, id)
- .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
+ .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
@@ -37,7 +40,7 @@ public class LoadingCompletedOutboxRepository {
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
}
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper()
- .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
+ .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker)
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now)
@@ -48,7 +51,7 @@ public class LoadingCompletedOutboxRepository {
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); }
private LambdaUpdateWrapper base(long id, String status) { return new LambdaUpdateWrapper()
- .eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
+ .eq(InboundOutboxEntity::getId,id).in(InboundOutboxEntity::getEventType,CROSS_REGION_EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus,status); }
private LambdaUpdateWrapper claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); }
private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; }
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java
index 30bf94202..c635fbe76 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java
@@ -15,7 +15,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
-/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */
+/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */
@Component @Slf4j
public class LoadingCompletedOutboxRelay {
@Resource private LoadingCompletedOutboxRepository repository;
@@ -39,12 +39,13 @@ public class LoadingCompletedOutboxRelay {
EventEnvelopeValidator.requireValid(event);
var response=client.receive(internalToken,event);
if(response==null || !response.successful() || response.data()==null
- || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
+ || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认头程跨区事件");
repository.published(row.getId(),workerId,LocalDateTime.now());
} catch(Exception exception) {
String error=safeError(exception);
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
- log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error);
+ log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}",
+ row.getEventType(), row.getEventKey(), error);
}
}
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java
new file mode 100644
index 000000000..27a76c2b4
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java
@@ -0,0 +1,77 @@
+package com.baiyue.firstleg.portschedule.service;
+
+import com.baiyue.common.event.EventEnvelope;
+import com.baiyue.common.event.EventTypes;
+import com.baiyue.firstleg.entity.InboundOutboxEntity;
+import com.baiyue.firstleg.mapper.InboundOutboxMapper;
+import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
+import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+class CarrierScheduleNodeOutboxServiceTest {
+ private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
+ private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
+ private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService();
+
+ @BeforeEach
+ void setUp() {
+ ReflectionTestUtils.setField(service, "outbox", mapper);
+ ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
+ ReflectionTestUtils.setField(service, "scheduleZone", "Asia/Shanghai");
+ }
+
+ @Test
+ void writesVersionedActualPickupReadyEnvelopeWithStringIds() throws Exception {
+ CarrierScheduleQueryEntity query = query();
+ CarrierScheduleNodeFactEntity fact = fact();
+
+ service.publish(query, fact, "ZCSU7881970");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
+ verify(mapper).insert(captor.capture());
+ InboundOutboxEntity row = captor.getValue();
+ EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
+ assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
+ assertThat(envelope.eventVersion()).isEqualTo(1);
+ assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING");
+ assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003");
+ assertThat(envelope.aggregateVersion()).isEqualTo(7);
+ assertThat(envelope.handoverVersion()).isEqualTo(8);
+ assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY");
+ assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z");
+ }
+
+ @Test
+ void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
+ CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
+ service.publish(query(), fact, "ZCSU7881970");
+ verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any());
+ }
+
+ private static CarrierScheduleQueryEntity query() {
+ CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
+ value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
+ value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L);
+ value.setVersion(7); return value;
+ }
+
+ private static CarrierScheduleNodeFactEntity fact() {
+ CarrierScheduleNodeFactEntity value = new CarrierScheduleNodeFactEntity();
+ value.setId(2088000000000000005L); value.setStandardNode("PICKUP_READY"); value.setNodeSequence(80);
+ value.setObservedAt(LocalDateTime.of(2026, 8, 25, 9, 2));
+ value.setActualAt(LocalDateTime.of(2026, 8, 25, 9, 0)); value.setProviderCode("ZIM");
+ value.setProviderEventId("evt-1"); value.setProviderNodeCode("AVAILABLE");
+ value.setSourceReference("ZIM:evt-1"); value.setRawPayloadHash("a".repeat(64)); return value;
+ }
+}
diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml
index f7204c15d..bdcda68d8 100644
--- a/gateway-overseas/src/main/resources/application-overseas.yml
+++ b/gateway-overseas/src/main/resources/application-overseas.yml
@@ -60,6 +60,7 @@ springdoc:
urls:
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
- { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform }
+ - { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda }
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
- { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform }
- { name: file-platform, url: /file-service/v3/api-docs/platform }
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java
index e83bb3312..345210960 100644
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java
+++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java
@@ -27,4 +27,15 @@ class OverseasLastlegRouteContractTest {
.doesNotContain("StripPrefix", "RewritePath");
}
}
+
+ @Test
+ void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException {
+ try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
+ assertThat(input).isNotNull();
+ String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda")
+ .contains("Path=/lastleg-service/v3/api-docs/**")
+ .doesNotContain("event-bridge-service/v3/api-docs/pda");
+ }
+ }
}
diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md
index 99b6a8795..d2853f74d 100644
--- a/lastleg-service/AGENTS.md
+++ b/lastleg-service/AGENTS.md
@@ -22,9 +22,9 @@
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
-| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API |
-| PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
-| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
+| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
+| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
+| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java
deleted file mode 100644
index 387334dae..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.baiyue.lastleg.inboundplan.pda.controller;
-
-import com.baiyue.common.core.api.ApiResponse;
-import com.baiyue.common.security.AuthenticatedUser;
-import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
-import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
-import jakarta.validation.Valid;
-import jakarta.annotation.Resource;
-import org.springframework.http.HttpStatus;
-import org.springframework.security.core.annotation.AuthenticationPrincipal;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.server.ResponseStatusException;
-
-@RestController
-@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
-public class InboundPalletAssignmentController {
- @Resource private InboundPdaWorkflowService service;
-
- @PostMapping("/{planPalletId}/assign")
- public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo, @PathVariable long planPalletId,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.Assign request) {
- requireOperate(user);
- service.assign(scope(user, customerId), planNo, planPalletId, request);
- return ApiResponse.success(null);
- }
-
- private String scope(AuthenticatedUser user, String requestedCustomer) {
- if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
- if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
- if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
- }
- return requestedCustomer;
- }
- return String.valueOf(user.tenantId());
- }
-
- private void requireOperate(AuthenticatedUser user) {
- if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
- if (user.permissions() == null || !user.permissions().contains(InboundPdaController.OPERATE_PERMISSION)) {
- throw new ResponseStatusException(HttpStatus.FORBIDDEN,
- InboundPdaController.OPERATE_PERMISSION + " permission is required");
- }
- }
-}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
deleted file mode 100644
index 29056ca92..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.baiyue.lastleg.inboundplan.pda.controller;
-
-import com.baiyue.common.core.api.ApiResponse;
-import com.baiyue.common.security.AuthenticatedUser;
-import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
-import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
-import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
-import jakarta.validation.Valid;
-import jakarta.annotation.Resource;
-import org.springframework.http.HttpStatus;
-import org.springframework.security.core.annotation.AuthenticationPrincipal;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.bind.annotation.RequestPart;
-import org.springframework.web.multipart.MultipartFile;
-import org.springframework.http.MediaType;
-import org.springframework.web.server.ResponseStatusException;
-
-import java.util.List;
-
-@RestController
-@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}")
-public class InboundPdaController {
- public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
- @Resource private InboundPdaWorkflowService service;
-
- @PostMapping("/push")
- public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.Push request) {
- requireOperate(user);
- return ApiResponse.success(service.push(scope(user, customerId), planNo, request));
- }
-
- @GetMapping
- public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @RequestParam(required = false) String customerId) {
- return ApiResponse.success(service.task(scope(user, customerId), planNo));
- }
-
- @PostMapping("/allocations/{allocationId}/count")
- public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @PathVariable long allocationId,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.PackageOperation request) {
- requireOperate(user);
- return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request));
- }
-
- @PostMapping("/allocations/{allocationId}/pick")
- public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @PathVariable long allocationId,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.PackageOperation request) {
- requireOperate(user);
- return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
- }
-
- @PostMapping("/cartons/scan")
- public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.ScanCarton request) {
- requireOperate(user);
- return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request));
- }
-
- @PostMapping("/cartons/remove")
- public ApiResponse removeCarton(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.RemoveCarton request) {
- requireOperate(user);
- return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request));
- }
-
- @GetMapping("/allocations/{allocationId}/merge-candidates")
- public ApiResponse> candidates(
- @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
- @PathVariable long allocationId, @RequestParam(required = false) String customerId) {
- return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId));
- }
-
- @PostMapping("/allocations/{allocationId}/merge")
- public ApiResponse merge(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @PathVariable long allocationId,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.ConfirmMerge request) {
- requireOperate(user);
- return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request));
- }
-
- @PostMapping("/complete")
- public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @RequestParam(required = false) String customerId) {
- requireOperate(user);
- return ApiResponse.success(service.complete(scope(user, customerId), planNo));
- }
-
- @PostMapping("/unload-photos")
- public ApiResponse uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user,
- @PathVariable String planNo,
- @RequestParam(required = false) String customerId,
- @Valid @RequestBody PdaRequests.UnloadPhotos request) {
- requireOperate(user);
- return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
- }
-
- @PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- public ApiResponse uploadUnloadPhotoFile(
- @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
- @RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) {
- requireOperate(user);
- return ApiResponse.success(service.uploadUnloadPhotoFile(
- scope(user, customerId), planNo, user.userId(), file));
- }
-
- private String scope(AuthenticatedUser user, String requestedCustomer) {
- if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
- if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
- if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
- }
- return requestedCustomer;
- }
- return String.valueOf(user.tenantId());
- }
-
- private void requireOperate(AuthenticatedUser user) {
- if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
- if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
- throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
- }
- }
-}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java
index 6adcf7ff5..fdb3e6d75 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
+import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -30,14 +31,14 @@ import java.util.List;
@Tag(name = "Overseas PDA container receiving")
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
public class ReceivingContainerController {
- public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
+ public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION;
@Resource private ReceivingContainerService service;
@GetMapping("/candidates")
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
public ApiResponse> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
- Identity identity = identity(user);
+ PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@@ -45,14 +46,14 @@ public class ReceivingContainerController {
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo) {
- Identity identity = identity(user);
+ PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
}
@PostMapping("/{containerNo}/prepare")
public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
- Identity identity = identity(user);
+ PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
containerNo, request));
}
@@ -60,7 +61,7 @@ public class ReceivingContainerController {
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
}
@@ -68,13 +69,13 @@ public class ReceivingContainerController {
@PostMapping("/current/sign")
public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
}
@GetMapping("/current")
public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.current(identity.operatorKey()));
}
@@ -82,7 +83,7 @@ public class ReceivingContainerController {
public ApiResponse resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
}
@@ -90,7 +91,7 @@ public class ReceivingContainerController {
public ApiResponse resolveSku(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
}
@@ -98,53 +99,38 @@ public class ReceivingContainerController {
public ApiResponse resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
}
@PostMapping("/current/receipts/bulk")
public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/receipts/carton")
public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/complete")
public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
- Identity identity = currentIdentity(user);
+ PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
- private static Identity identity(AuthenticatedUser user) {
- if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
- || !"PLATFORM".equals(user.appCode())) {
- throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required");
- }
- if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
- throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
- }
- if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
- throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse");
- }
- String operator = String.valueOf(user.userId());
- return new Identity(operator,
- user.username() == null || user.username().isBlank() ? operator : user.username(),
- user.overseasWarehouseId());
+ private static PdaWarehouseIdentity identity(AuthenticatedUser user) {
+ return PdaWarehouseIdentity.require(user);
}
- private Identity currentIdentity(AuthenticatedUser user) {
- Identity identity = identity(user);
+ private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) {
+ PdaWarehouseIdentity identity = identity(user);
service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId());
return identity;
}
-
- private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { }
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java
new file mode 100644
index 000000000..cbbf5139b
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java
@@ -0,0 +1,32 @@
+package com.baiyue.lastleg.pda.security;
+
+import com.baiyue.common.security.AuthenticatedUser;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) {
+ public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
+ public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
+
+ public static PdaWarehouseIdentity require(AuthenticatedUser user) {
+ if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
+ || !"PLATFORM".equals(user.appCode())) {
+ throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
+ "Authenticated platform warehouse PDA identity is required");
+ }
+ if (!user.permissions().contains(PICKER_ROLE)) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required");
+ }
+ if (!user.permissions().contains(OPERATE_PERMISSION)) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
+ }
+ if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN,
+ "PDA operator must be bound to one overseas warehouse");
+ }
+ String operator = String.valueOf(user.userId());
+ return new PdaWarehouseIdentity(operator,
+ user.username() == null || user.username().isBlank() ? operator : user.username(),
+ user.overseasWarehouseId());
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java
new file mode 100644
index 000000000..aaeef8112
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java
@@ -0,0 +1,15 @@
+package com.baiyue.lastleg.pda.warehouse.dto;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public final class PdaWarehouseRequests {
+ private PdaWarehouseRequests() { }
+
+ public record Scan(@NotBlank @Size(max = 128) String code) { }
+ public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) {
+ public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java
new file mode 100644
index 000000000..cb5b3921e
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java
@@ -0,0 +1,23 @@
+package com.baiyue.lastleg.pda.warehouse.dto;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public final class PdaWarehouseResponses {
+ private PdaWarehouseResponses() { }
+
+ public record InventoryScan(String queryCode, List items) { }
+ public record InventoryItem(String customerId, String customerName, long balanceId, long positionId,
+ String skuCode, String productName, String inboundCode, String cartonMark,
+ long palletId, String palletCode, Long locationId, String locationCode,
+ long availablePackages, long holdPackages, long receivingPackages,
+ String status, long version) { }
+ public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg,
+ String status, long occupiedPackages, int inventoryPositionCount) { }
+ public record Pallet(long palletId, String palletCode, String status, int version,
+ long locationId, String locationCode, long occupiedPackages,
+ int inventoryPositionCount, List inventory) { }
+ public record Operation(long operationId, String operationType, String businessKey, String status,
+ String summary, String customerId, LocalDateTime occurredAt) { }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java
new file mode 100644
index 000000000..59e428c49
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java
@@ -0,0 +1,26 @@
+package com.baiyue.lastleg.pda.warehouse.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("ll_pda_operation_history")
+public class PdaOperationHistory {
+ @TableId(type = IdType.INPUT)
+ private Long id;
+ private Long overseasWarehouseId;
+ private String customerId;
+ private String operatorKey;
+ private String operationType;
+ private String businessKey;
+ private String status;
+ private String idempotencyKey;
+ private String requestHash;
+ private String summary;
+ private LocalDateTime occurredAt;
+ private LocalDateTime createdAt;
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java
new file mode 100644
index 000000000..0fab0336f
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java
@@ -0,0 +1,72 @@
+package com.baiyue.lastleg.pda.warehouse.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
+import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface PdaWarehouseMapper extends BaseMapper {
+ String INVENTORY_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
+ "COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," +
+ "COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," +
+ "COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," +
+ "c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
+ "p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," +
+ "p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " +
+ "ON p.balance_id=b.id AND p.customer_id=b.customer_id " +
+ "LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " +
+ "LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " +
+ "AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' ";
+
+ @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
+ "AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " +
+ "OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " +
+ "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
+ "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
+ List scanInventory(@Param("warehouseId") String warehouseId,
+ @Param("code") String code);
+
+ @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
+ "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
+ "ORDER BY b.customer_id,b.inbound_code,p.id")
+ List inventoryByPallet(@Param("warehouseId") String warehouseId,
+ @Param("palletId") long palletId,
+ @Param("code") String ignored);
+
+ @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
+ "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
+ "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
+ "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
+ List inventoryByLocation(@Param("warehouseId") String warehouseId,
+ @Param("locationId") long locationId,
+ @Param("locationCode") String locationCode,
+ @Param("code") String ignored);
+
+ @Select("")
+ List history(
+ @Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
+ @Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit);
+
+ @Select("")
+ long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
+ @Param("operationType") String operationType);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java
new file mode 100644
index 000000000..a34b41d97
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java
@@ -0,0 +1,25 @@
+package com.baiyue.lastleg.pda.warehouse.model;
+
+import lombok.Data;
+
+@Data
+public class PdaInventoryProjection {
+ private String customerId;
+ private String customerName;
+ private Long balanceId;
+ private Long positionId;
+ private String skuCode;
+ private String productNameZh;
+ private String productNameEn;
+ private String inboundCode;
+ private String cartonMark;
+ private Long palletId;
+ private String palletCode;
+ private Long locationId;
+ private String locationCode;
+ private Long availablePackages;
+ private Long holdPackages;
+ private Long receivingPackages;
+ private String status;
+ private Long version;
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java
index b1d1b1803..b1afcaa80 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java
@@ -75,6 +75,15 @@ public class PlatformUsTruckOrderController {
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
}
+ @PostMapping("/{orderNo}/review")
+ @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
+ public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user,
+ @RequestParam String customerId, @PathVariable String orderNo,
+ @Valid @RequestBody UsTruckOrderRequests.Review request) {
+ requirePermission(user, OverseasPermissions.TRUCK_STATUS);
+ return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request));
+ }
+
@PostMapping("/{orderNo}/receive-pallets")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
@Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
@@ -85,6 +94,66 @@ public class PlatformUsTruckOrderController {
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
}
+ @PostMapping("/{orderNo}/review-pallet-overage")
+ @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
+ public ApiResponse reviewPalletOverage(
+ @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
+ @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReviewPalletOverage request) {
+ requirePermission(user, OverseasPermissions.TRUCK_STATUS);
+ return ApiResponse.success(service.reviewPalletOverage(
+ requiredCustomer(customerId), orderNo, operator(user), request));
+ }
+
+ @PostMapping("/{orderNo}/allocate-pallets")
+ @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
+ public ApiResponse allocatePallets(
+ @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
+ @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.AllocatePallets request) {
+ requirePermission(user, OverseasPermissions.TRUCK_STATUS);
+ return ApiResponse.success(service.allocatePallets(
+ requiredCustomer(customerId), orderNo, operator(user), request));
+ }
+
+ @PostMapping("/{orderNo}/prepare-dispatch")
+ @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
+ public ApiResponse prepareDispatch(
+ @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
+ @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
+ requirePermission(user, OverseasPermissions.TRUCK_STATUS);
+ return ApiResponse.success(service.prepareDispatch(
+ requiredCustomer(customerId), orderNo, operator(user), request));
+ }
+
+ @PostMapping("/{orderNo}/dispatch")
+ @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
+ public ApiResponse dispatch(
+ @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
+ @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
+ requirePermission(user, OverseasPermissions.TRUCK_STATUS);
+ return ApiResponse.success(service.dispatch(
+ requiredCustomer(customerId), orderNo, operator(user), request));
+ }
+
+ @PostMapping("/{orderNo}/start-transit")
+ @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
+ public ApiResponse startTransit(
+ @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
+ @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
+ requirePermission(user, OverseasPermissions.TRUCK_STATUS);
+ return ApiResponse.success(service.startTransit(
+ requiredCustomer(customerId), orderNo, operator(user), request));
+ }
+
+ @PostMapping("/{orderNo}/complete")
+ @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
+ public ApiResponse complete(
+ @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
+ @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
+ requirePermission(user, OverseasPermissions.TRUCK_STATUS);
+ return ApiResponse.success(service.complete(
+ requiredCustomer(customerId), orderNo, operator(user), request));
+ }
+
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java
index 120364ad3..9dc8d9646 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java
@@ -4,7 +4,10 @@ import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
@@ -71,7 +74,31 @@ public final class UsTruckOrderRequests {
public record ChangeStatus(
@NotNull @Min(0) Long version,
- @NotBlank @Size(max = 32) String status,
+ @NotBlank @Pattern(regexp = "CANCELLED") String status,
+ @NotBlank @Size(max = 255) String idempotencyKey,
+ @Size(max = 1000) String remark) {}
+
+ public record Review(
+ @NotNull @Min(0) Long version,
+ @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
+ @NotBlank @Size(max = 255) String idempotencyKey,
+ @Size(max = 1000) String remark) {}
+
+ public record ReviewPalletOverage(
+ @NotNull @Min(0) Long version,
+ @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
+ @NotBlank @Size(max = 255) String idempotencyKey,
+ @Size(max = 1000) String remark) {}
+
+ public record AllocatePallets(
+ @NotNull @Min(0) Long version,
+ @NotEmpty @Size(max = 500) List<@NotNull @Positive Long> palletIds,
+ @NotBlank @Size(max = 255) String idempotencyKey,
+ @Size(max = 1000) String remark) {}
+
+ public record Command(
+ @NotNull @Min(0) Long version,
+ @NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record ReceivePallets(
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java
index 6169954b8..2370d6afe 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java
@@ -11,13 +11,14 @@ public final class UsTruckOrderResponses {
public record Summary(
Long id, String customerId, String orderNo, String transportType,
String origin, String destination, String containerType, int palletCount, int packageCount,
- int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount,
+ int receivedPalletCount, int allocatedPalletCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record History(
- String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {}
+ String operationType, String fromStatus, String toStatus, String operatorId,
+ String idempotencyKey, String remark, LocalDateTime createdAt) {}
public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status,
Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java
index 264ab0905..6f6d5eb79 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java
@@ -25,7 +25,6 @@ public class UsTruckOrder {
private Integer packageCount;
private Integer receivedPalletCount;
private Integer allocatedPalletCount;
- private Integer shippedPalletCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
@@ -37,8 +36,6 @@ public class UsTruckOrder {
private String remark;
private String idempotencyKey;
private String requestHash;
- private String receiptIdempotencyKey;
- private String receiptRequestHash;
private String createdBy;
private Long version;
private LocalDateTime createdAt;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java
new file mode 100644
index 000000000..a9bfb4087
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java
@@ -0,0 +1,22 @@
+package com.baiyue.lastleg.trucking.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("ll_us_truck_order_operation")
+public class UsTruckOrderOperation {
+ @TableId(type = IdType.INPUT) private Long id;
+ private Long orderId;
+ private String customerId;
+ private String operationType;
+ private String idempotencyKey;
+ private String requestHash;
+ private Long resultVersion;
+ private String createdBy;
+ private LocalDateTime createdAt;
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java
index be4fee492..3f4602de5 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java
@@ -13,9 +13,11 @@ public class UsTruckOrderStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
+ private String operationType;
private String fromStatus;
private String toStatus;
private String operatorId;
+ private String idempotencyKey;
private String remark;
private LocalDateTime createdAt;
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java
new file mode 100644
index 000000000..92d12f53e
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java
@@ -0,0 +1,15 @@
+package com.baiyue.lastleg.trucking.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface UsTruckOrderOperationMapper extends BaseMapper {
+ @Select("SELECT * FROM ll_us_truck_order_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1")
+ UsTruckOrderOperation findKey(@Param("customerId") String customerId,
+ @Param("orderId") Long orderId,
+ @Param("key") String key);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java
index 4dd11a600..7f336fd7f 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java
@@ -7,9 +7,11 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
+import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
+import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.baiyue.lastleg.security.OverseasPermissions;
@@ -30,6 +32,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
+import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
@@ -55,20 +58,12 @@ public class UsTruckOrderService {
private static final Set STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
- private static final java.util.Map> TRANSITIONS = java.util.Map.ofEntries(
- java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)),
- java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)),
- java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)),
- java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)),
- java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)),
- java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)),
- java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)),
- java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)),
- java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)),
- java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED)));
+ private static final Set CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
+ PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH);
private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
@Resource private UsTruckOrderMapper orders;
+ @Resource private UsTruckOrderOperationMapper operations;
@Resource private UsTruckOrderPalletMapper pallets;
@Resource private UsTruckOrderStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@@ -90,7 +85,7 @@ public class UsTruckOrderService {
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null);
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
- order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
+ order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
@@ -103,7 +98,7 @@ public class UsTruckOrderService {
if (concurrent != null) return replay(customerId, concurrent, hash);
throw e;
}
- history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
+ history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
@@ -150,7 +145,7 @@ public class UsTruckOrderService {
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
touch(order); orders.updateById(order);
- history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now());
+ history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now());
return detail(customerId, orderNo);
}
@@ -158,34 +153,37 @@ public class UsTruckOrderService {
public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ChangeStatus request) {
UsTruckOrder order = lock(customerId, orderNo);
- if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
- String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
- String from = order.getStatus();
- if (!next.equals(from)) {
- if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
- throw conflict("Unsupported truck order transition: " + from + " -> " + next);
- }
- order.setStatus(next);
- if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order);
- touch(order); orders.updateById(order);
- history(order, from, next, operatorId, request.remark(), LocalDateTime.now());
+ UsTruckOrderResponses.Detail replay = replayOperation(order, "CANCEL", request.idempotencyKey(), request);
+ if (replay != null) return replay;
+ requireVersion(order, request.version());
+ if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) {
+ throw conflict("Only cancellable truck orders can use the generic status endpoint");
}
- return detail(customerId, orderNo);
+ return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request,
+ order.getStatus(), CANCELLED, request.remark());
+ }
+
+ @Transactional
+ public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId,
+ UsTruckOrderRequests.Review request) {
+ UsTruckOrder order = lock(customerId, orderNo);
+ UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request);
+ if (replay != null) return replay;
+ requireVersion(order, request.version());
+ if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed");
+ String next = "APPROVE".equals(norm(request.decision())) ? WAITING_INBOUND : REJECTED;
+ if (WAITING_INBOUND.equals(next)) createPlannedPallets(order);
+ return transition(order, operatorId, "REVIEW", request.idempotencyKey(), request,
+ PENDING_REVIEW, next, request.remark());
}
@Transactional
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ReceivePallets request) {
UsTruckOrder order = lock(customerId, orderNo);
- String key = request.idempotencyKey().trim();
- String requestHash = hash(request);
- if (key.equals(order.getReceiptIdempotencyKey())) {
- if (!requestHash.equals(order.getReceiptRequestHash())) {
- throw conflict("Receipt idempotency key was reused with different pallet data");
- }
- return detail(customerId, orderNo);
- }
- if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
+ UsTruckOrderResponses.Detail replay = replayOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request);
+ if (replay != null) return replay;
+ requireVersion(order, request.version());
if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) {
throw conflict("Only waiting or receiving orders can receive pallets");
}
@@ -220,18 +218,118 @@ public class UsTruckOrderService {
}
}
order.setReceivedPalletCount(actual);
- order.setReceiptIdempotencyKey(key);
- order.setReceiptRequestHash(requestHash);
String from = order.getStatus();
String next = actual > planned ? PALLET_OVERAGE_REVIEW
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
order.setStatus(next);
touch(order);
orders.updateById(order);
- history(order, from, next, operatorId, request.remark(), now);
+ recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now);
+ history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now);
return detail(customerId, orderNo);
}
+ @Transactional
+ public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId,
+ UsTruckOrderRequests.ReviewPalletOverage request) {
+ UsTruckOrder order = lock(customerId, orderNo);
+ UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW_PALLET_OVERAGE",
+ request.idempotencyKey(), request);
+ if (replay != null) return replay;
+ requireVersion(order, request.version());
+ if (!PALLET_OVERAGE_REVIEW.equals(order.getStatus())) {
+ throw conflict("Only pallet-overage-review orders can be reviewed");
+ }
+ boolean approve = "APPROVE".equals(norm(request.decision()));
+ List rows = pallets.listOrder(customerId, order.getId());
+ LocalDateTime now = LocalDateTime.now();
+ int accepted = value(order.getPalletCount());
+ for (UsTruckOrderPallet pallet : rows) {
+ if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue;
+ pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED");
+ pallet.setUpdatedAt(now);
+ pallets.updateById(pallet);
+ }
+ if (approve) {
+ accepted = value(order.getReceivedPalletCount());
+ order.setPalletCount(accepted);
+ } else {
+ order.setReceivedPalletCount(accepted);
+ }
+ order.setStatus(WAITING_CONSOLIDATION);
+ touch(order);
+ orders.updateById(order);
+ recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now);
+ history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION,
+ operatorId, request.idempotencyKey(), request.remark(), now);
+ return detail(customerId, orderNo);
+ }
+
+ @Transactional
+ public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId,
+ UsTruckOrderRequests.AllocatePallets request) {
+ UsTruckOrder order = lock(customerId, orderNo);
+ UsTruckOrderResponses.Detail replay = replayOperation(order, "ALLOCATE_PALLETS",
+ request.idempotencyKey(), request);
+ if (replay != null) return replay;
+ requireVersion(order, request.version());
+ if (!Set.of(WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED).contains(order.getStatus())) {
+ throw conflict("Only waiting-consolidation or partially-allocated orders can allocate pallets");
+ }
+ Set requested = new HashSet<>(request.palletIds());
+ if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique");
+ List rows = pallets.listOrder(customerId, order.getId());
+ List selected = rows.stream().filter(row -> requested.contains(row.getId())).toList();
+ if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) {
+ throw conflict("Selected pallets must belong to the order and be received but unallocated");
+ }
+ LocalDateTime now = LocalDateTime.now();
+ for (UsTruckOrderPallet pallet : selected) {
+ pallet.setStatus("ALLOCATED");
+ pallet.setUpdatedAt(now);
+ pallets.updateById(pallet);
+ }
+ int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count()
+ + selected.size();
+ int accepted = value(order.getReceivedPalletCount());
+ if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets");
+ String from = order.getStatus();
+ String next = allocated == accepted ? ALLOCATED : PARTIALLY_ALLOCATED;
+ order.setAllocatedPalletCount(allocated);
+ order.setStatus(next);
+ touch(order);
+ orders.updateById(order);
+ recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now);
+ history(order, "ALLOCATE_PALLETS", from, next, operatorId,
+ request.idempotencyKey(), request.remark(), now);
+ return detail(customerId, orderNo);
+ }
+
+ @Transactional
+ public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId,
+ UsTruckOrderRequests.Command request) {
+ return command(customerId, orderNo, operatorId, "PREPARE_DISPATCH", ALLOCATED,
+ PENDING_DISPATCH, request);
+ }
+
+ @Transactional
+ public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId,
+ UsTruckOrderRequests.Command request) {
+ return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request);
+ }
+
+ @Transactional
+ public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId,
+ UsTruckOrderRequests.Command request) {
+ return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request);
+ }
+
+ @Transactional
+ public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId,
+ UsTruckOrderRequests.Command request) {
+ return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request);
+ }
+
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List hs) {
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java
index 8270d8f01..850071823 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java
@@ -5,8 +5,7 @@ import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController;
import com.baiyue.lastleg.dispatch.controller.SmartDispatchController;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
-import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
-import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController;
+import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController;
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -26,10 +25,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class Phase4BrowserApiBoundaryTest {
@Test
- void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() {
- assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/"));
- assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/"));
- assertFalse(basePath(InboundPdaController.class).contains("/api/platform/"));
+ void onlyStableContainerReceivingUsesThePdaInboundAccessDomain() throws Exception {
+ assertTrue(basePath(ReceivingContainerController.class).startsWith("/api/pda/v1/"));
+ assertFalse(basePath(ReceivingContainerController.class).contains("/api/platform/"));
+ Path controllers = Path.of("src/main/java/com/baiyue/lastleg/inboundplan/pda/controller");
+ String source = Files.walk(controllers).filter(path -> path.toString().endsWith(".java"))
+ .map(path -> {
+ try { return Files.readString(path); }
+ catch (Exception exception) { throw new IllegalStateException(exception); }
+ }).collect(Collectors.joining("\n"));
+ assertFalse(source.contains("/api/pda/v1/last-leg/inbound-plans/{planNo}"));
+ assertFalse(source.contains("@RequestParam(required = false) String customerId"));
}
@Test
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java
deleted file mode 100644
index b00867007..000000000
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.baiyue.lastleg.inboundplan.pda.controller;
-
-import com.baiyue.common.security.AuthenticatedUser;
-import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
-import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.util.ReflectionTestUtils;
-import org.springframework.web.server.ResponseStatusException;
-
-import java.util.Set;
-
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-
-class InboundPalletAssignmentControllerTest {
- private final InboundPdaWorkflowService service = mock(InboundPdaWorkflowService.class);
- private final InboundPalletAssignmentController controller = controller();
- private final PdaRequests.Assign request = new PdaRequests.Assign(1L, 2L, 3L, "assign-key");
-
- @Test
- void assignmentRequiresPdaOperatePermissionAndExplicitPlatformCustomer() {
- assertThrows(ResponseStatusException.class,
- () -> controller.assign(user(Set.of()), "PLAN-1", 4L, "100", request));
- assertThrows(ResponseStatusException.class,
- () -> controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, null, request));
-
- controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, "100", request);
-
- verify(service).assign("100", "PLAN-1", 4L, request);
- }
-
- private AuthenticatedUser user(Set permissions) {
- return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "PDA", permissions);
- }
-
- private InboundPalletAssignmentController controller() {
- InboundPalletAssignmentController value = new InboundPalletAssignmentController();
- ReflectionTestUtils.setField(value, "service", service);
- return value;
- }
-}
diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs
index bd977c3e0..2fd8d05f9 100644
--- a/scripts/admin-test-env-modules/phase3-loading.mjs
+++ b/scripts/admin-test-env-modules/phase3-loading.mjs
@@ -1029,13 +1029,111 @@ export async function run(context) {
const dynamicCandidatesResponse = await context.request('GET',
`${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS)
- const dynamicCandidate = Array.isArray(dynamicCandidates) ? dynamicCandidates.find((item) =>
- id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : null
+ const markerOwnedDynamicCandidates = Array.isArray(dynamicCandidates) ? dynamicCandidates.filter((item) =>
+ id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : []
+ const dynamicCandidate = markerOwnedDynamicCandidates[0]
+ const skippedExceptionCandidate = markerOwnedDynamicCandidates[1]
+ const partialExceptionCandidate = markerOwnedDynamicCandidates.find((item, index) =>
+ index > 1 && Number(item?.availablePackageCount) >= 2)
context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse,
Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId
&& id(dynamicCandidate?.warehouseId) === warehouseId
&& dynamicCandidate?.channelCode === initial?.summary?.channelCode,
`inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
+ context.assert(MODULE, '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', dynamicCandidatesResponse,
+ Boolean(skippedExceptionCandidate?.inboundDetailId) && Boolean(partialExceptionCandidate?.inboundDetailId)
+ && id(skippedExceptionCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId)
+ && id(dynamicCandidate?.inboundDetailId) !== id(skippedExceptionCandidate?.inboundDetailId)
+ && id(dynamicCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId),
+ `candidateCount=${markerOwnedDynamicCandidates.length}, skipDetailId=${id(skippedExceptionCandidate?.inboundDetailId) || 'missing'}, partialDetailId=${id(partialExceptionCandidate?.inboundDetailId) || 'missing'}`, OPTIONS)
+ if (skippedExceptionCandidate?.inboundDetailId && partialExceptionCandidate?.inboundDetailId) {
+ const beforeSkipPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
+ const beforeSkipPlan = context.apiSuccess(MODULE, 'SKIP 异常场景前读取计划即时版本', beforeSkipPlanResponse, '', OPTIONS)
+ const skipAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
+ inboundId: incrementalInboundId, inboundDetailId: id(skippedExceptionCandidate.inboundDetailId), packageCount: 1,
+ expectedVersion: Number(beforeSkipPlan?.version), operationId: `${context.marker}-exception-skip-assign`.slice(0, 128),
+ })
+ const skipAssignedPlan = context.apiSuccess(MODULE, '加入本轮待跳拣异常明细', skipAssignmentResponse, '', OPTIONS)
+ const skipPlanItem = skipAssignedPlan?.items?.find?.((item) =>
+ id(item?.inboundDetailId) === id(skippedExceptionCandidate.inboundDetailId))
+ const skipTaskResponse = await context.request('GET', taskDetailPath)
+ const skipTask = context.apiSuccess(MODULE, '读取待跳拣异常 PDA 明细', skipTaskResponse, '', OPTIONS)
+ const skipTaskItem = skipTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(skipPlanItem?.id))
+ const skipInventoryResponse = await context.request('GET',
+ `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
+ const skipInventory = context.apiSuccess(MODULE, '跳拣前刷新实时库存快照', skipInventoryResponse, '', OPTIONS)
+ const skipReason = `${context.marker}-箱唛破损待复核`
+ const skipBatchResponse = await request(context, 'POST', batchPath, {
+ warehouseId, loadingPlanId, containerBookingId,
+ expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken,
+ items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version),
+ reason: skipReason }],
+ }, { 'Idempotency-Key': `${context.marker}-exception-skip-batch`.slice(0, 128) })
+ const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS)
+ const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id))
+ context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse,
+ Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED'
+ && Number(skippedTaskItem?.pickedPackageCount) === 0
+ && skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING',
+ `itemStatus=${skippedTaskItem?.itemStatus || 'missing'}, picked=${skippedTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${skippedTask?.workflowStatus || 'missing'}`, OPTIONS)
+
+ const beforePartialPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
+ const beforePartialPlan = context.apiSuccess(MODULE, 'PARTIAL 异常场景前读取计划即时版本', beforePartialPlanResponse, '', OPTIONS)
+ const partialAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
+ inboundId: incrementalInboundId, inboundDetailId: id(partialExceptionCandidate.inboundDetailId), packageCount: 2,
+ expectedVersion: Number(beforePartialPlan?.version), operationId: `${context.marker}-exception-partial-assign`.slice(0, 128),
+ })
+ const partialAssignedPlan = context.apiSuccess(MODULE, '加入本轮部分拣货异常明细', partialAssignmentResponse, '', OPTIONS)
+ const partialPlanItem = partialAssignedPlan?.items?.find?.((item) =>
+ id(item?.inboundDetailId) === id(partialExceptionCandidate.inboundDetailId))
+ const unresolvedTaskResponse = await context.request('GET', taskDetailPath)
+ const unresolvedTask = context.apiSuccess(MODULE, '读取存在未解决异常的 PDA 任务', unresolvedTaskResponse, '', OPTIONS)
+ const unresolvedTaskItem = unresolvedTask?.items?.find?.((item) =>
+ id(item?.loadingPlanItemId) === id(partialPlanItem?.id))
+ const operationsBeforeUnresolvedCompleteResponse = await context.request('GET',
+ `${pdaBase}/tasks/${canonicalTaskId}/operations`)
+ const operationsBeforeUnresolvedComplete = context.apiSuccess(MODULE,
+ '未解决异常完成前读取 PDA 操作审计基线', operationsBeforeUnresolvedCompleteResponse, '', OPTIONS)
+ const unresolvedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
+ expectedTaskVersion: Number(unresolvedTask?.version),
+ operationId: `${context.marker}-complete-with-unresolved`.slice(0, 128),
+ remark: `${context.marker}-must-reject-unresolved`,
+ }, { 'Idempotency-Key': `${context.marker}-complete-with-unresolved-key`.slice(0, 128) })
+ context.addStep(MODULE, '存在未解决异常明细时禁止确认装柜完成', unresolvedComplete,
+ unresolvedComplete.http === 200 && codeOf(unresolvedComplete) === 'A0409'
+ && String(unresolvedComplete.body?.message || '').includes('尚未处理'),
+ responseEvidence(unresolvedComplete), OPTIONS)
+ const afterUnresolvedCompleteTaskResponse = await context.request('GET', taskDetailPath)
+ const afterUnresolvedCompleteTask = context.apiSuccess(MODULE,
+ '未解决异常完成失败后回查 PDA 任务', afterUnresolvedCompleteTaskResponse, '', OPTIONS)
+ const operationsAfterUnresolvedCompleteResponse = await context.request('GET',
+ `${pdaBase}/tasks/${canonicalTaskId}/operations`)
+ const operationsAfterUnresolvedComplete = context.apiSuccess(MODULE,
+ '未解决异常完成失败后回查 PDA 操作审计', operationsAfterUnresolvedCompleteResponse, '', OPTIONS)
+ context.assert(MODULE, '未解决异常完成失败后 task/items/operation 全部零写入', afterUnresolvedCompleteTaskResponse,
+ Number(afterUnresolvedCompleteTask?.version) === Number(unresolvedTask?.version)
+ && taskItemsSignature(afterUnresolvedCompleteTask) === taskItemsSignature(unresolvedTask)
+ && operationsSignature(operationsAfterUnresolvedComplete) === operationsSignature(operationsBeforeUnresolvedComplete),
+ `taskVersion=${afterUnresolvedCompleteTask?.version ?? 'missing'}, operations=${operationsAfterUnresolvedComplete?.length ?? 'missing'}`, OPTIONS)
+
+ const partialInventoryResponse = await context.request('GET',
+ `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
+ const partialInventory = context.apiSuccess(MODULE, '部分拣货前刷新实时库存快照', partialInventoryResponse, '', OPTIONS)
+ const partialBatchResponse = await request(context, 'POST', batchPath, {
+ warehouseId, loadingPlanId, containerBookingId,
+ expectedVersion: Number(afterUnresolvedCompleteTask?.version),
+ inventorySnapshotToken: partialInventory?.snapshotToken,
+ items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1,
+ releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }],
+ }, { 'Idempotency-Key': `${context.marker}-exception-partial-batch`.slice(0, 128) })
+ const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS)
+ const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id))
+ context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse,
+ partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2
+ && Number(partialTaskItem?.pickedPackageCount) === 1
+ && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING',
+ `itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS)
+ }
if (dynamicCandidate?.inboundDetailId) {
const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS)
diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
index 4d1662b28..d54cf72d4 100644
--- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
+++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
@@ -458,6 +458,32 @@ test('phase3 loading proves stale inventory zero-write and defers final completi
assert.doesNotMatch(loading, /complete-before-downstream/)
})
+test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstream completion', () => {
+ const loading = source('./phase3-loading.mjs')
+ assertOrder(loading, [
+ '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细',
+ '加入本轮待跳拣异常明细',
+ 'SKIP 携带异常原因并原子解决待拣明细',
+ '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW',
+ '加入本轮部分拣货异常明细',
+ '未解决异常完成前读取 PDA 操作审计基线',
+ '存在未解决异常明细时禁止确认装柜完成',
+ '未解决异常完成失败后 task/items/operation 全部零写入',
+ '合法 PARTIAL 解决未完成明细',
+ '部分拣货数量守恒并恢复最终可确认状态',
+ '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING',
+ ])
+ assert.match(loading, /action: 'SKIP', version: Number\(skipTaskItem\?\.version\),\s*reason: skipReason/)
+ assert.match(loading, /skipReason = `\$\{context\.marker\}-箱唛破损待复核`/)
+ assert.match(loading, /itemStatus === 'SKIPPED'[\s\S]*pickedPackageCount\) === 0/)
+ assert.match(loading, /action: 'PARTIAL', finalPickedPackageCount: 1/)
+ assert.match(loading, /plannedPackageCount\) === 2[\s\S]*pickedPackageCount\) === 1/)
+ assert.match(loading, /String\(unresolvedComplete\.body\?\.message \|\| ''\)\.includes\('尚未处理'\)/)
+ assert.match(loading, /taskItemsSignature\(afterUnresolvedCompleteTask\) === taskItemsSignature\(unresolvedTask\)/)
+ assert.match(loading, /operationsSignature\(operationsAfterUnresolvedComplete\) === operationsSignature\(operationsBeforeUnresolvedComplete\)/)
+ assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/)
+})
+
test('phase3 loading fixture forces A0 through reversible public location status APIs', () => {
const entry = source('../admin-test-env-acceptance.mjs')
const measurement = source('./inbound-measurement.mjs')
diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java
index 7ede1b836..be951ca52 100644
--- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java
+++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java
@@ -87,7 +87,8 @@ public class PlatformWarehouseResourceController {
}
@PostMapping("/pallets")
- @Operation(summary = "Create a warehouse pallet", description = "Platform access required")
+ @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')")
+ @Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE)
public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java
index 6af72807f..65588f077 100644
--- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java
+++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java
@@ -10,7 +10,6 @@ import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
class WarehouseResourcePermissionContractTest {
private static final String PREFIX = "platform:overseas-warehouse:";
@@ -28,15 +27,9 @@ class WarehouseResourcePermissionContractTest {
assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete");
assertPermission(methods.get("pallets"), PREFIX + "pallet:list");
assertPermission(methods.get("pallet"), PREFIX + "pallet:list");
+ assertPermission(methods.get("createPallet"), PREFIX + "pallet:create");
assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit");
assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete");
-
- Method createPallet = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods())
- .filter(method -> "createPallet".equals(method.getName()))
- .findFirst()
- .orElseThrow();
- assertNull(createPallet.getAnnotation(PreAuthorize.class));
- assertEquals("Platform access required", createPallet.getAnnotation(Operation.class).description());
}
private static void assertPermission(Method method, String authority) {