This commit is contained in:
2026-08-25 09:52:49 +08:00
parent b211121365
commit b67217332a
37 changed files with 1009 additions and 343 deletions
@@ -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";
@@ -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("船节点交接供应商审计字段不完整");
}
}
}
@@ -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));
}
}
@@ -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());
@@ -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"));
}
}
@@ -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 <A extends java.lang.annotation.Annotation> String sql(Method method, Class<A> 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+", " ");
}
}
@@ -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<BridgeDeliveryAttempt> 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<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture());
@@ -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); }
}
@@ -13,11 +13,14 @@ import java.util.List;
@Repository
public class LoadingCompletedOutboxRepository {
private static final List<String> 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<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.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<InboundOutboxEntity>()
.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<InboundOutboxEntity>()
.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<InboundOutboxEntity> base(long id, String status) { return new LambdaUpdateWrapper<InboundOutboxEntity>()
.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<InboundOutboxEntity> 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; }
@@ -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);
}
}
}
@@ -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<InboundOutboxEntity> 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;
}
}
@@ -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 }
@@ -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");
}
}
}
+3 -3
View File
@@ -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` |
@@ -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<Void> 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");
}
}
}
@@ -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<PdaResponses.Task> 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<PdaResponses.Task> 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<PdaResponses.Operation> 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<PdaResponses.Operation> 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<PdaResponses.Carton> 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<PdaResponses.Carton> 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<List<PdaResponses.MergeCandidate>> 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<PdaResponses.Merge> 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<PdaResponses.Task> 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<PdaResponses.Task> 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<PdaResponses.UnloadPhotoFile> 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");
}
}
}
@@ -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<List<ReceivingContainerResponses.Candidate>> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.Photo> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.CartonResolved> 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<ReceivingContainerResponses.SkuResolved> 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<ReceivingContainerResponses.PalletResolved> 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<ReceivingContainerResponses.Receipt> 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<ReceivingContainerResponses.Receipt> 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<ReceivingContainerResponses.Session> 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) { }
}
@@ -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());
}
}
@@ -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; }
}
}
@@ -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<InventoryItem> 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<InventoryItem> inventory) { }
public record Operation(long operationId, String operationType, String businessKey, String status,
String summary, String customerId, LocalDateTime occurredAt) { }
}
@@ -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;
}
@@ -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<PdaOperationHistory> {
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<PdaInventoryProjection> 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<PdaInventoryProjection> 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<PdaInventoryProjection> inventoryByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode,
@Param("code") String ignored);
@Select("<script>SELECT operation_id,operation_type,business_key,status,summary,customer_id,occurred_at FROM (" +
"SELECT h.id operation_id,h.operation_type,h.business_key,h.status,h.summary,h.customer_id,h.occurred_at " +
"FROM ll_pda_operation_history h WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id,CONCAT('RECEIVING_',o.action),s.container_no,'COMPLETED'," +
"CONCAT('柜号作业 ',s.container_no),s.customer_id,o.created_at " +
"FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>" +
") x ORDER BY occurred_at DESC,operation_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses.Operation> history(
@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM (SELECT h.id FROM ll_pda_operation_history h " +
"WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>) x</script>")
long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType);
}
@@ -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;
}
@@ -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<UsTruckOrderResponses.Detail> 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<UsTruckOrderResponses.Detail> 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<UsTruckOrderResponses.Detail> 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<UsTruckOrderResponses.Detail> 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<UsTruckOrderResponses.Detail> 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<UsTruckOrderResponses.Detail> 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<UsTruckOrderResponses.Detail> 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) {
@@ -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(
@@ -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) {}
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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<UsTruckOrderOperation> {
@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);
}
@@ -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<String> 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<String, Set<String>> 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<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH);
private static final Set<String> 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);
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");
}
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());
return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request,
order.getStatus(), CANCELLED, request.remark());
}
return detail(customerId, orderNo);
@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<UsTruckOrderPallet> 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<Long> requested = new HashSet<>(request.palletIds());
if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique");
List<UsTruckOrderPallet> rows = pallets.listOrder(customerId, order.getId());
List<UsTruckOrderPallet> 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<UsTruckOrderStatusHistory> hs) {
@@ -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
@@ -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<String> 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;
}
}
@@ -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)
@@ -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')
@@ -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<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
@@ -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) {