From 4ae236fe111db405f2f9678f4445f158bb50024c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 15:24:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CarrierScheduleQueryService.java | 36 ++++++++++------ .../CarrierScheduleNodeOutboxServiceTest.java | 19 ++++++++ .../CarrierScheduleQueryServiceTest.java | 21 +++++++++ .../pda/dto/ReceivingContainerRequests.java | 4 +- .../pda/dto/ReceivingContainerResponses.java | 4 +- .../service/ReceivingContainerService.java | 18 ++++++-- .../openapi/overseas-business-v1.yaml | 4 +- ...ReceivingContainerServiceBehaviorTest.java | 22 +++++----- scripts/admin-test-env-modules/README.md | 3 +- .../fulfillment-list-field-contract.mjs | 2 - .../phase3-dispatch-gate.mjs | 17 ++++---- .../phase3-http-fail-closed-contract.test.mjs | 6 ++- .../phase3-outbound-aggregate.mjs | 26 ++++++----- .../phase3-outbound-dispatch.mjs | 16 +++---- ...4-departure-pickup-chain-contract.test.mjs | 43 +++++++++++++++++++ .../phase3-waiting-for-loading.mjs | 9 ++-- .../phase4-overseas-pda-contract.test.mjs | 9 +++- .../phase4-overseas-pda.mjs | 31 ++++++------- 18 files changed, 198 insertions(+), 92 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 3c87e8347..6e61b468b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -122,19 +123,30 @@ public class CarrierScheduleQueryService { value.setManualReason(outboundManualReason(strategy, booking)); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); - try { repository.insert(value); } - catch (DuplicateKeyException exception) { - CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, - containerBookingId); - if (concurrent != null) return concurrent; - throw exception; + String persistenceStage = "QUERY_TASK"; + try { + try { repository.insert(value); } + catch (DuplicateKeyException exception) { + CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (concurrent != null) return concurrent; + throw exception; + } + persistenceStage = "NODE_FACT"; + CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", + CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", + sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + if (departed != null) { + persistenceStage = "CROSS_REGION_OUTBOX"; + nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); + } + persistenceStage = "QUERY_HISTORY"; + addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, + operatorId, operatorName); + } catch (DataIntegrityViolationException exception) { + throw conflict("船节点持久化契约与当前服务不一致(" + persistenceStage + + "),请确认OTHER/MANUAL约束及节点事实表已初始化"); } - CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", - CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", - sha256("OUTBOUND_CONFIRM|" + containerBookingId)); - if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); - addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, - operatorId, operatorName); return value; } 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 index ee314b6d1..0f5bea2a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -61,6 +61,25 @@ class CarrierScheduleNodeOutboxServiceTest { verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); } + @Test + void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception { + CarrierScheduleQueryEntity query = query(); + query.setVersion(0); + CarrierScheduleNodeFactEntity fact = fact(); + fact.setStandardNode("DEPARTED_WAREHOUSE"); fact.setNodeSequence(10); + fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM"); + fact.setProviderNodeCode("OUTBOUND_CONFIRM"); + + service.publish(query, fact, "CSNU1234567"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(envelope.aggregateVersion()).isZero(); + assertThat(envelope.handoverVersion()).isEqualTo(1L); + assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("DEPARTED_WAREHOUSE"); + } + private static CarrierScheduleQueryEntity query() { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 06102d733..55d41c6f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -23,6 +23,7 @@ import org.mockito.Mock; import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.dao.DataIntegrityViolationException; import java.time.Clock; import java.time.Instant; @@ -166,6 +167,26 @@ class CarrierScheduleQueryServiceTest { verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); } + @Test + void outboundPersistenceContractMismatchReturnsExplicitBusinessConflict() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + org.mockito.Mockito.doThrow(new DataIntegrityViolationException("check constraint rejected")) + .when(repository).insert(any(CarrierScheduleQueryEntity.class)); + + assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin")) + .isInstanceOf(BizException.class) + .hasMessageContaining("船节点持久化契约与当前服务不一致") + .hasMessageContaining("QUERY_TASK") + .hasMessageContaining("OTHER/MANUAL"); + verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + } + @Test void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { CarrierScheduleQueryEntity task = task(0, 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 4b3e93b8b..1b02cf639 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.dto; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -14,7 +15,8 @@ public final class ReceivingContainerRequests { public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, @NotBlank @Size(max = 255) String idempotencyKey) { } - public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { } + public record SignedPhoto(@NotNull ReceivingPhotoRole role, + @NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { } public record CartonResolve(@NotBlank String cartonMark) { } public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { } public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 656c83a1a..dda326fbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -17,7 +17,7 @@ public final class ReceivingContainerResponses { int totalPackages, int receivedPackages, int pendingPackages, List inbounds) { } - public record SignedPhoto(ReceivingPhotoRole role, long fileId) { } + public record SignedPhoto(ReceivingPhotoRole role, String fileId) { } public record Inbound(String inboundCode, String destinationType, String overseasWarehouseId, String overseasWarehouseName, @@ -48,5 +48,5 @@ public final class ReceivingContainerResponses { String destinationType, String palletCode, int acceptedPackages, int receivedPackages, int pendingPackages, String sessionStatus) { } - public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } + public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 092e88c99..cab86a93b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -169,7 +169,7 @@ public class ReceivingContainerService { String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); - return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); + return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); } @Transactional @@ -181,7 +181,7 @@ public class ReceivingContainerService { String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); List photos = validateSignPhotos(request); - List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); + List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); LocalDateTime now = LocalDateTime.now(); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); @@ -513,9 +513,9 @@ public class ReceivingContainerService { Set roles = request.photos().stream() .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); - Set fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId) + Set fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId())) .collect(Collectors.toSet()); - if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) { + if (fileIds.size() != 3) { throw conflict("Unload photo file IDs must be positive and unique"); } return request.photos().stream() @@ -523,6 +523,16 @@ public class ReceivingContainerService { .map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList(); } + private long positiveFileId(String value) { + try { + long fileId = Long.parseLong(value); + if (fileId > 0) return fileId; + } catch (NumberFormatException ignored) { + // Validation normally rejects this before the service; keep the domain fail-closed. + } + throw conflict("Unload photo file ID must be a positive decimal string"); + } + private SkuRef requireSku(InboundRef ref, String skuCode) { SkuRef result = skuRef(ref, skuCode); if (result == null) throw notFound("SKU is not part of the selected inbound code"); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 1c5d4c33f..86375b1fe 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -623,7 +623,7 @@ components: required: [role, fileId] properties: role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] } - fileId: { type: integer, format: int64, minimum: 1 } + fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string } ReceivingBulk: type: object required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey] @@ -701,7 +701,7 @@ components: type: object required: [fileId, fileName, contentType, sizeBytes] properties: - fileId: { type: integer, format: int64 } + fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string } fileName: { type: string } contentType: { type: string } sizeBytes: { type: integer, format: int64 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index fcd4ef4a4..6fedbef7b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -304,16 +304,18 @@ class ReceivingContainerServiceBehaviorTest { when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L), - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key"); + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3"), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2092476334483931138"), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "2")), "sign-key"); var response = service.sign("7", "receiver", request); org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role") .containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL, ReceivingPhotoRole.DOOR_OPEN); - verify(unloadPhotos).validate("100", 8L, 1L); + org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId") + .containsExactly("2092476334483931138", "2", "3"); + verify(unloadPhotos).validate("100", 8L, 2092476334483931138L); verify(unloadPhotos).validate("100", 8L, 2L); verify(unloadPhotos).validate("100", 8L, 3L); } @@ -322,9 +324,9 @@ class ReceivingContainerServiceBehaviorTest { void signRejectsDuplicateRolesBeforeFileValidation() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L), - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign"); + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2"), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-sign"); assertThatThrownBy(() -> service.sign("7", "receiver", request)) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); @@ -345,9 +347,9 @@ class ReceivingContainerServiceBehaviorTest { void signRejectsDuplicateFileIdsBeforeFileValidation() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L), - new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files"); + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "1"), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-files"); assertThatThrownBy(() -> service.sign("7", "receiver", request)) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 6f81b8808..38908e6e9 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -49,7 +49,7 @@ export async function run(context) { | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | -| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` | @@ -59,6 +59,7 @@ export async function run(context) { | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | | 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | +| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | | 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index 83674f498..9450f1031 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -151,8 +151,6 @@ export function outboundAggregateFieldGaps(record) { containerNo: isNonBlank, loadingPlanId: isPositiveId, loadingPlanStatus: isNonBlank, - pickTaskId: isPositiveId, - pickTaskStatus: isNonBlank, oceanBookingId: isPositiveId, oceanBookingStatus: isNonBlank, dispatchStatus: isNonBlank, diff --git a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs index 2dcc9aada..a93505f8e 100644 --- a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs @@ -12,15 +12,16 @@ export async function run(context) { '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return } - const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}` - const response = await context.request('GET', `${BASE}/check-gate?${selector}`) - const gate = context.apiSuccess(MODULE, '复核国内出仓统一硬门禁', response, selector, OPTIONS) - const items = Array.isArray(gate?.items) ? gate.items : [] + const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}/gate`) + const gate = context.apiSuccess(MODULE, '复核国内出仓柜级统一硬门禁', response, + `containerBookingId=${fixture.containerBookingId}`, OPTIONS) + const items = Array.isArray(gate?.inbounds) + ? gate.inbounds.flatMap((item) => Array.isArray(item?.gate?.items) ? item.gate.items : []) : [] const required = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED', - 'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE', - 'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE'] - context.assert(MODULE, '八项业务门禁全部由服务端真实判定并通过', response, + 'OCEAN_BOOKING_RELEASED', 'LOADING_EVIDENCE_COMPLETE', 'CUSTOMS_CLEARANCE'] + context.assert(MODULE, '六项硬门禁全部由服务端真实判定并通过', response, gate?.passed === true && required.every((code) => items.some((item) => item.code === code - && item.passed === true && item.actual !== 'MODULE_UNAVAILABLE' && item.actual !== 'ASSESSMENT_ERROR')), + && item.passed === true && item.actual !== 'MODULE_UNAVAILABLE' && item.actual !== 'ASSESSMENT_ERROR')) + && items.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)), `passed=${gate?.passed ?? 'missing'}, gates=${items.map((item) => `${item.code}:${item.passed}`).join(',')}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index 008087f06..1a4f1bfa4 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -35,8 +35,9 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/) assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) assert.match(dispatch, /'CUSTOMS_CLEARANCE'/) - assert.doesNotMatch(dispatch, /expectedCodes[\s\S]{0,240}'TRUCK_DEPARTED'|expectedCodes[\s\S]{0,240}'SAILING_SCHEDULE'/) - assert.match(aggregate, /'CUSTOMS', 'SAILING_SCHEDULE'/) + assert.match(dispatch, /const expectedCodes = \['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',[\s\S]{0,120}'CUSTOMS_CLEARANCE'\]/) + assert.match(aggregate, /'OCEAN_BOOKING', 'CUSTOMS', 'OUTBOUND_DISPATCH'/) + assert.match(aggregate, /拖车和独立船期时间线只作参考/) assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/) }) @@ -62,4 +63,5 @@ test('outbound lifecycle checks confirm replay, immutable departure reverse boun assert.match(waiting, /confirmations\.length === 1 && reversed\.length === 0/) assert.match(waiting, /实际离仓节点冻结后保持一次确认且零次成功撤销/) assert.match(waiting, /detail\?\.aggregate\?\.dispatchStatus === 'WAITING_FOR_LOADING'/) + assert.match(waiting, /inbounds\?\.every/) }) diff --git a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs index 6ab6c718f..282305e19 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs @@ -23,29 +23,27 @@ export async function run(context) { '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return } - const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}` - const response = await context.request('GET', `${BASE}/detail?${selector}`) - const detail = context.apiSuccess(MODULE, '复核出仓主聚合与统一时间线', response, selector, OPTIONS) + const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}`) + const detail = context.apiSuccess(MODULE, '复核柜级出仓主聚合与统一时间线', response, + `containerBookingId=${fixture.containerBookingId}`, OPTIONS) const aggregate = detail?.aggregate + const inbound = aggregate?.inbounds?.find?.((item) => id(item?.inboundId) === id(fixture.inboundId)) const timeline = Array.isArray(detail?.timeline) ? detail.timeline : [] const expectedModules = ['INBOUND', 'LOADING_PLAN', 'PDA_PICK', 'LOADING_EVIDENCE', - 'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'CUSTOMS', 'SAILING_SCHEDULE', 'OUTBOUND_DISPATCH'] + 'OCEAN_BOOKING', 'CUSTOMS', 'OUTBOUND_DISPATCH'] context.assert(MODULE, '聚合唯一关联本轮订单、入库单、柜次和客户', response, - id(aggregate?.orderId) === id(fixture.orderId) && id(aggregate?.tenantId) === id(fixture.tenantId) - && id(aggregate?.inboundId) === id(fixture.inboundId) + id(inbound?.orderId) === id(fixture.orderId) && id(inbound?.tenantId) === id(fixture.tenantId) + && id(inbound?.inboundId) === id(fixture.inboundId) && id(aggregate?.containerBookingId) === id(fixture.containerBookingId), - `orderId=${id(aggregate?.orderId) || 'missing'}, inboundId=${id(aggregate?.inboundId) || 'missing'}, containerBookingId=${id(aggregate?.containerBookingId) || 'missing'}`, OPTIONS) + `orderId=${id(inbound?.orderId) || 'missing'}, inboundId=${id(inbound?.inboundId) || 'missing'}, containerBookingId=${id(aggregate?.containerBookingId) || 'missing'}`, OPTIONS) context.assert(MODULE, '统一时间线有序且各子流程包含审计字段', response, timeline.length > 0 && ordered(timeline) && expectedModules.every((module) => timeline.some((item) => item.module === module)) && timeline.every((item) => Boolean(item.action) && Boolean(item.occurredAt)), `timeline=${timeline.length}, modules=${[...new Set(timeline.map((item) => item.module))].join(',')}`, OPTIONS) - const other = context.createdResources?.phase3ContainerLoading - if (other?.inboundId && other?.containerBookingId - && (id(other.inboundId) !== id(fixture.inboundId) || id(other.containerBookingId) !== id(fixture.containerBookingId))) { - const forged = await context.request('GET', `${BASE}/detail?inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(other.containerBookingId)}`) - context.addStep(MODULE, '伪造跨资源入库单与柜次组合不可匹配', forged, rejected(forged), - `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) - } + context.assert(MODULE, '拖车和独立船期时间线只作参考', response, + ['TRUCK_APPOINTMENT', 'SAILING_SCHEDULE'].every((module) => timeline + .filter((item) => item.module === module).every((item) => Boolean(item.action) && Boolean(item.occurredAt))), + `referenceModules=${timeline.filter((item) => ['TRUCK_APPOINTMENT', 'SAILING_SCHEDULE'].includes(item.module)).length}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index b41aa12c0..cd8a4ae98 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -60,8 +60,7 @@ export async function run(context) { const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`) const detail = context.apiSuccess(MODULE, '查询出仓主聚合与整体时间线', detailResponse, '', OPTIONS) context.assert(MODULE, '整体时间线返回稳定数组且聚合定位不漂移', detailResponse, - Array.isArray(detail?.timeline) && id(detail?.aggregate?.inboundId) === inboundId - && id(detail?.aggregate?.containerBookingId) === containerBookingId + Array.isArray(detail?.timeline) && id(detail?.aggregate?.containerBookingId) === containerBookingId && detail?.aggregate?.inbounds?.some?.((item) => id(item?.inboundId) === inboundId), `timeline=${detail?.timeline?.length ?? 'n/a'}`, OPTIONS) @@ -81,7 +80,6 @@ export async function run(context) { `truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS) const expectedVersion = Number(aggregate.version) - const actionBody = { inboundId, containerBookingId, expectedVersion, remark: context.marker } const confirmKey = `${context.marker}-outbound-confirm`.slice(0, 128) if (!gate?.passed) { const blocked = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, { expectedVersion, remark: context.marker }, @@ -105,9 +103,9 @@ export async function run(context) { const confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS) context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse, confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' - && confirmed?.aggregate?.inboundStatus === 'LOADED' + && confirmed?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') && Number(confirmed?.aggregate?.version) === expectedVersion + 1, - `dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS) + `dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbounds=${confirmed?.aggregate?.inbounds?.length ?? 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS) if (!confirmed?.aggregate) return const replayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody, @@ -145,22 +143,22 @@ export async function run(context) { const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS) context.assert(MODULE, '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', finalDetailResponse, finalDetail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' - && finalDetail?.aggregate?.inboundStatus === 'LOADED' + && finalDetail?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') && Number(finalDetail?.aggregate?.version) === Number(confirmed?.aggregate?.version) && finalDetail?.timeline?.filter?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH').length === 1 && !finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), - `dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS) + `dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbounds=${finalDetail?.aggregate?.inbounds?.length ?? 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS) if (confirmed?.aggregate) { context.createdResources.phase3OutboundDispatch = { marker: context.marker, inboundId, containerBookingId, orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId), confirmedStatus: confirmed.aggregate.dispatchStatus, - confirmedInboundStatus: confirmed.aggregate.inboundStatus, + confirmedInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null, confirmedVersion: Number(confirmed.aggregate.version), finalStatus: confirmed.aggregate.dispatchStatus, - finalInboundStatus: confirmed.aggregate.inboundStatus, + finalInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null, finalVersion: Number(confirmed.aggregate.version), } } diff --git a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs new file mode 100644 index 000000000..2ee659802 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const read = (name) => readFileSync(resolve(here, name), 'utf8') + +test('current-marker departure batch proves four documents photos customs and container-level confirm', () => { + const customs = read('phase3-customs.mjs') + const outbound = read('phase3-outbound-dispatch.mjs') + for (const type of ['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER']) { + assert.match(customs, new RegExp(type)) + } + for (const evidence of ['首次上传', '回显', '下载', '更新', '完成']) assert.match(customs, new RegExp(evidence)) + assert.match(customs, /photos\.length >= 5/) + assert.match(customs, /四类整柜资料必须完成后才允许进入出仓确认/) + assert.match(outbound, /CUSTOMS_CLEARANCE/) + assert.match(outbound, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) + assert.match(outbound, /\$\{BASE\}\/\$\{encodeURIComponent\(containerBookingId\)\}\/confirm/) + assert.match(outbound, /confirm-dispatch 幂等重放/) + assert.match(outbound, /相同 Idempotency-Key 不同请求拒绝/) + assert.match(outbound, /实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409/) + assert.doesNotMatch(outbound, /\/confirm-dispatch|\/reverse-dispatch|\/check-gate\?/) +}) + +test('outbound-created query supports automated or manual carrier then reaches PICKUP_READY PDA candidate', () => { + const query = read('phase3-carrier-schedule-queries.mjs') + const nodes = read('phase4-carrier-nodes.mjs') + const pda = read('phase4-overseas-pda.mjs') + assert.match(query, /出仓自动建任务兼容自动船司与未知船司人工处理/) + assert.match(query, /manualCarrier && task\?\.status === 'MANUAL_REQUIRED'/) + assert.match(nodes, /'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'/) + assert.match(nodes, /const pickupReadyIndex = STANDARD_NODES\.indexOf\('PICKUP_READY'\)/) + assert.match(nodes, /createdResources\.phase4CarrierPickupReady/) + assert.match(pda, /targetContainerNo[\s\S]*phase3Loading\?\.containerNo/) + assert.match(pda, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/) + assert.match(pda, /seen\.has\(targetContainerNo\)/) + assert.match(pda, /targetCorrelated/) + assert.match(pda, /deploymentBlocker: true/) + assert.doesNotMatch(pda, /addCoverageGap\([^\n]*PICKUP_READY/) +}) diff --git a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs index 6729d98f0..95e5ee054 100644 --- a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs @@ -12,9 +12,9 @@ export async function run(context) { '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return } - const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}` - const response = await context.request('GET', `${BASE}/detail?${selector}`) - const detail = context.apiSuccess(MODULE, '回查 WAITING_FOR_LOADING 状态历史', response, selector, OPTIONS) + const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}`) + const detail = context.apiSuccess(MODULE, '回查柜级 WAITING_FOR_LOADING 状态历史', response, + `containerBookingId=${fixture.containerBookingId}`, OPTIONS) const history = Array.isArray(detail?.timeline) ? detail.timeline.filter((item) => item.module === 'OUTBOUND_DISPATCH') : [] const confirmations = history.filter((item) => item.action === 'CONFIRM_DISPATCH') @@ -27,7 +27,8 @@ export async function run(context) { && Boolean(confirmed?.operatorId) && Boolean(confirmed?.operatorName) && Boolean(confirmed?.occurredAt), `confirmed=${confirmed?.fromStatus || 'missing'}->${confirmed?.toStatus || 'missing'}, version=${fixture.confirmedVersion ?? 'missing'}`, OPTIONS) context.assert(MODULE, '实际离仓节点冻结后保持一次确认且零次成功撤销', response, - detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && detail?.aggregate?.inboundStatus === 'LOADED' + detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' + && detail?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') && confirmations.length === 1 && reversed.length === 0, `current=${detail?.aggregate?.dispatchStatus || 'missing'}, confirmations=${confirmations.length}, reversals=${reversed.length}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index d59239abe..61ac6ec3a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -47,7 +47,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /模式硬拒绝/) assert.match(source, /current返回404正常空态/) assert.match(source, /安全扫描返回A0400/) - assert.match(source, /customerId: 0/) + assert.match(source, /warehouse-admin\?tenantId=0&.*capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/) + assert.match(source, /193000000000200003.*193000000000200004/) + assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/) assert.match(source, /overseasWarehouseId, password/) assert.match(source, /inbound\?\.overseasWarehouseId/) assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/) @@ -120,6 +122,10 @@ test('empty candidate response cannot become a zero-business-request pass', asyn if (method === 'GET' && requestPath.endsWith('/permissions')) return response([ { id: permissionId, permissionCode: 'lastleg:pda:operate' }, ], requestPath, method) + if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response({ + records: [{ id: '193000000000200003', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], + page: 1, size: 200, total: 1, pages: 1, + }, requestPath, method) if (method === 'POST' && requestPath.endsWith('/users')) return response({ membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId], overseasWarehouseId: String(body.overseasWarehouseId), status: 'ACTIVE', @@ -130,7 +136,6 @@ test('empty candidate response cannot become a zero-business-request pass', asyn }, async requestOverseas(method, requestPath, body, headers, token) { calls.push({ channel: 'overseas', method, requestPath }) - if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('900', requestPath, method) if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } return response([], requestPath, method) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 975b35e5e..ba7c333d3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -559,9 +559,9 @@ export async function run(context) { const merchantToken = await loginMerchantForNegativeProbe(context) const pickerAccess = await resolvePickerAccess(context) if (!pickerAccess) return - const sharedWarehouse = await createSharedWarehouse(context) + const sharedWarehouse = await resolveSharedWarehouse(context) if (!sharedWarehouse) { - blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓') + blockerGap(context, '海外PDA共享实际收货仓', '国内Warehouse主数据没有ACTIVE且启用RECEIVING的OVERSEAS固定作业仓') return } context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id @@ -623,21 +623,14 @@ export async function run(context) { } } -async function createSharedWarehouse(context) { - const suffix = markerSuffix(context) - const payload = { - customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50), - warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS', - countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance', - contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`, - status: 'ACTIVE', remark: `${context.marker}-shared-receiving`, - address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles', - district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' }, - capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }], - } - const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload) - const warehouseId = id(created?.body?.data) - context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created, - success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS) - return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null +async function resolveSharedWarehouse(context) { + const path = '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE' + const response = await context.request('GET', path) + const rows = records(response?.body?.data) + const fixedIds = new Set(['193000000000200003', '193000000000200004']) + const warehouse = success(response) ? rows.find((item) => fixedIds.has(id(item?.id))) : null + context.addStep(MODULE, '读取国内Warehouse权威海外PDA实际收货仓', response, + Boolean(warehouse) && warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE', + `warehouseId=${id(warehouse?.id) || 'missing'}, warehouseName=${warehouse?.warehouseName || warehouse?.name || 'missing'}; ${trace(response)}`, OPTIONS) + return warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null }