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) {