From d5e9ba6d4c9e0a38e001483d54099c4263c7ad1d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 10:38:28 +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 --- .../common/web/GlobalExceptionHandler.java | 24 ++ .../web/GlobalExceptionHandlerTest.java | 16 + .../query/dto/InventoryQueryRequests.java | 17 +- .../service/PartnerWarehouseDirectory.java | 3 + .../InventoryQueryControllerTest.java | 2 +- .../service/InventoryQueryServiceTest.java | 17 +- .../service/LocalLooseboardServiceTest.java | 11 + .../service/OverseasQuoteServiceTest.java | 39 ++ .../PartnerWarehouseDirectoryTest.java | 13 + .../service/UsTruckOrderServiceTest.java | 13 + scripts/admin-test-env-acceptance.mjs | 69 +++- scripts/admin-test-env-modules/README.md | 10 + .../phase4-carrier-nodes-contract.test.mjs | 99 +++++ .../phase4-carrier-nodes.mjs | 86 ++++ ...phase4-overseas-business-contract.test.mjs | 104 +++++ .../phase4-overseas-business.mjs | 222 +++++++++++ ...se4-overseas-master-data-contract.test.mjs | 215 ++++++++++ .../phase4-overseas-master-data.mjs | 375 ++++++++++++++++++ ...hase4-overseas-warehouse-contract.test.mjs | 111 ++++++ .../phase4-overseas-warehouse.mjs | 242 +++++++++++ ...verseas-acceptance-entry-contract.test.mjs | 32 ++ 21 files changed, 1705 insertions(+), 15 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteServiceTest.java create mode 100644 scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-carrier-nodes.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-business.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-master-data.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs create mode 100644 scripts/phase4-overseas-acceptance-entry-contract.test.mjs diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index f0c42012b..3cc9626c3 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -21,6 +21,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; import java.util.LinkedHashMap; import java.util.Map; @@ -79,6 +80,15 @@ public class GlobalExceptionHandler { return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request); } + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus( + ResponseStatusException exception, HttpServletRequest request) { + ResultCode code = resultCode(exception.getStatusCode().value()); + String message = exception.getReason() == null || exception.getReason().isBlank() + ? code.message() : exception.getReason(); + return failure(code, message, request); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest request) { log.error("Unhandled request failure requestId={} traceId={}", @@ -123,4 +133,18 @@ public class GlobalExceptionHandler { meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment(); } } + + private ResultCode resultCode(int status) { + return switch (status) { + case 400 -> ResultCode.BAD_REQUEST; + case 401 -> ResultCode.UNAUTHORIZED; + case 403 -> ResultCode.FORBIDDEN; + case 404 -> ResultCode.NOT_FOUND; + case 409 -> ResultCode.CONFLICT; + case 422 -> ResultCode.VALIDATION_FAILED; + case 429 -> ResultCode.TOO_MANY_REQUESTS; + case 503 -> ResultCode.SERVICE_UNAVAILABLE; + default -> ResultCode.INTERNAL_ERROR; + }; + } } diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index 5f44b0d87..febb9da67 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -13,6 +13,8 @@ import org.springframework.validation.FieldError; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; import java.lang.reflect.Method; @@ -87,6 +89,20 @@ class GlobalExceptionHandlerTest { .tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0); } + @Test + void responseStatusConflictMapsToCanonicalConflictInsteadOfInternalError() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(CorrelationContext.REQUEST_ID, "stale-version"); + + var response = handler.handleResponseStatus( + new ResponseStatusException(HttpStatus.CONFLICT, "Order was changed; refresh and retry"), request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code()); + assertThat(response.getBody().message()).isEqualTo("Order was changed; refresh and retry"); + assertThat(response.getBody().requestId()).isEqualTo("stale-version"); + } + private void paging(long page) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java index 1ab5b2fed..de7467004 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inventory.query.dto; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -10,8 +11,8 @@ public final class InventoryQueryRequests { } public record BalanceQuery( - @Min(1) long page, - @Min(1) @Max(200) long size, + @Min(1) @Schema(defaultValue = "1", minimum = "1") Long page, + @Min(1) @Max(200) @Schema(defaultValue = "20", minimum = "1", maximum = "200") Long size, String customerName, String overseasWarehouseId, String overseasWarehouseCode, @@ -26,8 +27,8 @@ public final class InventoryQueryRequests { String inventoryStatus ) { public BalanceQuery { - if (page == 0) page = 1; - if (size == 0) size = 20; + if (page == null || page == 0) page = 1L; + if (size == null || size == 0) size = 20L; } } @@ -35,8 +36,8 @@ public final class InventoryQueryRequests { } public record LedgerQuery( - @Min(1) long page, - @Min(1) @Max(200) long size, + @Min(1) @Schema(defaultValue = "1", minimum = "1") Long page, + @Min(1) @Max(200) @Schema(defaultValue = "20", minimum = "1", maximum = "200") Long size, String overseasWarehouseId, String destinationWarehouseCode, String businessType, @@ -52,8 +53,8 @@ public final class InventoryQueryRequests { LocalDateTime occurredTo ) { public LedgerQuery { - if (page == 0) page = 1; - if (size == 0) size = 20; + if (page == null || page == 0) page = 1L; + if (size == null || size == 0) size = 20L; } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java index 0d1124ffc..24e4734d9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java @@ -40,6 +40,9 @@ public class PartnerWarehouseDirectory { .map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(), value.warehouseCode(), value.warehouseName(), value.countryCode())) .toList(); + // An empty directory can be a short-lived cross-region visibility gap after a partner + // warehouse is created. Do not preserve that absence for the normal 30-minute TTL. + if (options.isEmpty()) return List.of(); snapshot = new Snapshot(options, now.plus(ttl)); return snapshot.options(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index 733076f47..b91dd3d2c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -18,7 +18,7 @@ class InventoryQueryControllerTest { private final InventoryQueryService service = mock(InventoryQueryService.class); private final InventoryQueryController controller = controller(); private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery( - 1, 20, null, null, null, null, null, null, null, null, null, null, null, null); + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null); @Test void merchantCannotOverrideCustomerScope() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index db09d04fa..ec68477b3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -105,7 +105,7 @@ class InventoryQueryServiceTest { @Test void reversedLedgerDateRangeIsRejected() { InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( - 1, 20, null, null, null, null, null, null, null, null, null, null, null, + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, java.time.LocalDateTime.of(2026, 8, 18, 0, 0), java.time.LocalDateTime.of(2026, 8, 17, 0, 0)); @@ -125,6 +125,19 @@ class InventoryQueryServiceTest { } private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) { - return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, null, null, null, null, status); + return new InventoryQueryRequests.BalanceQuery(1L, 20L, null, null, null, null, null, null, null, null, null, null, null, status); + } + + @Test + void omittedPaginationDefaultsForSummaryAndExports() { + InventoryQueryRequests.BalanceQuery balances = new InventoryQueryRequests.BalanceQuery( + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + InventoryQueryRequests.LedgerQuery ledgers = new InventoryQueryRequests.LedgerQuery( + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertEquals(1L, balances.page()); + assertEquals(20L, balances.size()); + assertEquals(1L, ledgers.page()); + assertEquals(20L, ledgers.size()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index cd60f508c..575f0a6e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -71,6 +71,17 @@ class LocalLooseboardServiceTest { assertEquals(409, error.getStatusCode().value()); } + @Test + void staleOrdinaryUpdateIsRejectedAsConflict() { + LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 2L); + when(orders.lockNo("100", "LB1")).thenReturn(order); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.update("100", "LB1", "7", update(1L))); + + assertEquals(409, error.getStatusCode().value()); + } + @Test void statusTransitionWritesHistoryAndVersion() { LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteServiceTest.java new file mode 100644 index 000000000..3e7715610 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.masterdata.quote.service; + +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests; +import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote; +import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OverseasQuoteServiceTest { + private OverseasQuoteMapper mapper; + private OverseasQuoteService service; + + @BeforeEach + void setUp() { + mapper = mock(OverseasQuoteMapper.class); + service = new OverseasQuoteService(); + ReflectionTestUtils.setField(service, "quotes", mapper); + } + + @Test + void rejectsStaleVersionAsConflict() { + OverseasQuote quote = new OverseasQuote(); + quote.setId(1L); + quote.setRecordVersion(4L); + quote.setStatus(OverseasQuoteService.INACTIVE); + when(mapper.lockById(1L)).thenReturn(quote); + + assertThatThrownBy(() -> service.changeStatus(1L, "9", + new OverseasQuoteRequests.ChangeStatus(OverseasQuoteService.ACTIVE, 3L))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409 CONFLICT"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java index 5d47f9e32..b226068b3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java @@ -53,4 +53,17 @@ class PartnerWarehouseDirectoryTest { directory.options(); verify(client, times(2)).activePartners("internal-token"); } + + @Test + void emptyDomesticResultIsNotCachedForThirtyMinutes() { + when(client.activePartners("internal-token")) + .thenReturn(ApiResponse.success(List.of())) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 101L, "US-ONT", "安大略合作仓", "US")))); + + assertThat(directory.options()).isEmpty(); + assertThat(directory.options()).singleElement().satisfies(option -> + assertThat(option.warehouseId()).isEqualTo("101")); + verify(client, times(2)).activePartners("internal-token"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index a26e52316..de1cf17df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -84,6 +84,19 @@ class UsTruckOrderServiceTest { assertEquals(409, error.getStatusCode().value()); } + @Test + void staleOrdinaryUpdateIsRejectedAsConflict() { + when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.PENDING_REVIEW, 2L)); + UsTruckOrderRequests.Update request = new UsTruckOrderRequests.Update(1L, + null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.update("100", "TK1", "7", request)); + + assertEquals(409, error.getStatusCode().value()); + } + @Test void detailIncludesBoundPallets() { UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 84b3f5819..16421131d 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -10,9 +10,10 @@ import { pathToFileURL } from 'node:url' import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' +const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { const normalized = [...modules] @@ -131,13 +132,14 @@ function usage() { 选项: --gateway Gateway 地址,默认 ${DEFAULT_GATEWAY} + --overseas-gateway 海外 Gateway 地址,默认 ${DEFAULT_OVERSEAS_GATEWAY} --username 管理员账号,默认 ${DEFAULT_USERNAME} --password 管理员密码,默认使用上方测试密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --output JSON 报告路径,默认 reports/ --help 显示帮助 -环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`) +环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`) } export function parseArguments(argv) { @@ -158,6 +160,7 @@ export function parseArguments(argv) { } return { gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''), + overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || DEFAULT_OVERSEAS_GATEWAY).replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME, password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD, modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)), @@ -189,6 +192,7 @@ export class AcceptanceRun { this.coverageGaps = [] this.context = {} this.token = '' + this.overseasToken = '' this.fatal = null } @@ -215,6 +219,29 @@ export class AcceptanceRun { } } + async requestOverseas(method, path, body = undefined, extraHeaders = {}) { + const headers = { Accept: 'application/json', ...extraHeaders } + if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}` + if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' + try { + const response = await fetch(`${this.options.overseasGateway}${path}`, { + method, headers, body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }) + const content = await response.text() + let payload = null + try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ } + return { + method, path, http: response.status, body: payload, + raw: payload === null ? content.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id'), + } + } catch (error) { + return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + } + } + async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) { const form = file instanceof FormData ? file : new FormData() if (!(file instanceof FormData)) { @@ -266,6 +293,31 @@ export class AcceptanceRun { } } + async requestOverseasBinary(method, path) { + const headers = { Accept: '*/*' } + if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}` + try { + const response = await fetch(`${this.options.overseasGateway}${path}`, { + method, headers, signal: AbortSignal.timeout(120_000), + }) + const bytes = new Uint8Array(await response.arrayBuffer()) + const contentType = response.headers.get('content-type') || '' + let payload = null + if (contentType.toLowerCase().includes('json')) { + try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ } + } + return { + method, path, http: response.status, body: payload, bytes, contentType, + raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id'), + } + } catch (error) { + return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '', + raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + } + } + addStep(module, name, response, passed, evidence = '', options = {}) { const step = { module, scenario: options.scenario || null, name, passed: Boolean(passed), @@ -792,6 +844,14 @@ export class AcceptanceRun { if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { this.token = login.accessToken + this.overseasToken = login.overseasAccessToken || '' + const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-')) + if (requiresOverseas) { + this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse, + Boolean(this.overseasToken) && Number(login.overseasExpiresIn) > 0, + `tokenPresent=${Boolean(this.overseasToken)}, expiresIn=${login.overseasExpiresIn ?? 'missing'}`, + { deploymentBlocker: true, scenario: 'phase4-dual-token' }) + } const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] @@ -856,7 +916,7 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.token].filter((value) => typeof value === 'string' && value.length >= 8) + const forbiddenValues = [this.options.password, this.token, this.overseasToken].filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, /\bBearer\s+[A-Za-z0-9._~-]{16,}/i, @@ -872,7 +932,8 @@ export class AcceptanceRun { const failed = this.steps.filter((step) => !step.passed).length return { reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, - gateway: this.options.gateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, + gateway: this.options.gateway, overseasGateway: this.options.overseasGateway, + generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && failed === 0, fatal: this.fatal, summary: { total: this.steps.length, passed: this.steps.length - failed, failed }, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 0f2c5e531..d7e5f736e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -54,10 +54,20 @@ export async function run(context) { | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | +| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-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` | `phase4-carrier-nodes-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;船节点禁止手工创建任务。所有模块继续禁止调用海外出库、派送、PDA 或内部接口。开发环境定向执行示例: + +```powershell +node scripts/admin-test-env-acceptance.mjs --gateway http://8.163.49.32:9000 --overseas-gateway http://8.163.49.32:9300 --modules phase4-overseas-warehouse,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes +``` + 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 ## WAIT_SPLIT 到 LOADED 冻结链 diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs new file mode 100644 index 000000000..729e39657 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8') + +function response(data) { return { http: 200, body: { code: '200', data } } } +function contextFor(routes) { + const calls = [] + return { + calls, + request: async (method, requestPath) => { + calls.push({ method, path: requestPath }) + assert.equal(method, 'GET') + assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`) + return response(routes[requestPath]) + }, + apiSuccess: (_module, _name, result) => { + assert.equal(result.http, 200) + assert.equal(result.body.code, '200') + return result.body.data + }, + assert: (_module, name, _result, condition, evidence) => { + assert.ok(condition, `${name}: ${evidence}`) + }, + } +} + +const task = { + id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001', + oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO', + trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED', + latestStandardNode: 'PICKUP_READY', latestNodeObservedAt: '2026-08-20T12:00:00', + lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00', + expiresAt: null, stoppedAt: '2026-08-20T12:01:00', stopReason: '目的港可提柜后停止', +} +const node = { + id: '40001', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY', + nodeSequence: 60, observedAt: '2026-08-20T12:00:00', estimatedAt: null, + actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', + sourceReference: null, createdAt: '2026-08-20T12:00:01', +} +const history = { + id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5, + reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00', +} +const carriers = [{ + carrierCode: 'COSCO', carrierName: 'COSCO', officialUrl: 'https://example.invalid', + accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false, +}] + +test('readonly module statically forbids writes and internal execution endpoints', () => { + assert.match(source, /context\.request\('GET'/) + assert.doesNotMatch(source, /\bfetch\s*\(/) + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`)) + } + for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) { + assert.doesNotMatch(source, new RegExp(endpoint.replace('/', '\\/'), 'i')) + } +}) + +test('canonical task and node sets are locked to the phase-4 read contract', () => { + assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED']) + assert.deepEqual(STANDARD_NODES, [ + 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY', + ]) +}) + +test('empty page validates read-only pagination without detail calls', async () => { + const context = contextFor({ + '/api/platform/v1/carrier-schedule-queries/carriers': carriers, + '/api/platform/v1/carrier-schedule-queries?page=1&size=20': { records: [], page: 1, size: 20, total: 0, pages: 0 }, + }) + await run(context) + assert.deepEqual(context.calls, [ + { method: 'GET', path: '/api/platform/v1/carrier-schedule-queries/carriers' }, + { method: 'GET', path: '/api/platform/v1/carrier-schedule-queries?page=1&size=20' }, + ]) +}) + +test('existing task reads page, detail and history with nullable node times', async () => { + const base = '/api/platform/v1/carrier-schedule-queries' + const context = contextFor({ + [`${base}/carriers`]: carriers, + [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, + [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], histories: [history] }, + [`${base}/${task.id}/history`]: [history], + }) + await run(context) + assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET']) + assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs new file mode 100644 index 000000000..bf02e09d2 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -0,0 +1,86 @@ +const MODULE = 'phase4-carrier-nodes' +const OPTIONS = { scenario: 'phase4-carrier-nodes-lifecycle', deploymentBlocker: true } +const BASE = '/api/platform/v1/carrier-schedule-queries' + +export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED']) +export const STANDARD_NODES = Object.freeze([ + 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY', +]) + +function id(value) { return value == null ? '' : String(value) } +function number(value) { return Number.isFinite(Number(value)) } +function nullableText(value) { return value == null || typeof value === 'string' } +function nullableId(value) { return value == null || Boolean(id(value)) } +function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } + +function validTask(task) { + return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) + && QUERY_STATUSES.includes(task?.status) + && nullableId(task?.containerBookingId) + && nullableText(task?.latestStandardNode) + && (task?.latestStandardNode == null || STANDARD_NODES.includes(task.latestStandardNode)) + && ['latestNodeObservedAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] + .every((field) => nullableIso(task?.[field])) + && nullableText(task?.stopReason) +} + +function validNode(node) { + return Boolean(id(node?.id) && node?.providerCode && node?.providerNodeCode) + && STANDARD_NODES.includes(node?.standardNode) + && number(node?.nodeSequence) && Number(node.nodeSequence) > 0 + && nullableIso(node?.observedAt) && node?.observedAt != null + && nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt) + && nullableText(node?.locationCode) && nullableText(node?.locationName) + && nullableText(node?.sourceReference) && nullableIso(node?.createdAt) +} + +function validHistory(item) { + return Boolean(id(item?.id) && item?.action) + && (item?.fromVersion == null || number(item.fromVersion)) + && (item?.toVersion == null || number(item.toVersion)) + && nullableText(item?.reason) && nullableId(item?.operatorId) + && nullableText(item?.operatorName) && nullableIso(item?.createdAt) +} + +export async function run(context) { + const carriersResponse = await context.request('GET', `${BASE}/carriers`) + const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS) + context.assert(MODULE, '船司辅助信息保持公开只读描述结构', carriersResponse, + Array.isArray(carriers) && carriers.every((item) => item?.carrierCode && item?.carrierName + && nullableText(item?.officialUrl) && nullableText(item?.accessMode) + && typeof item?.automated === 'boolean' && typeof item?.coverageGap === 'boolean'), + `count=${Array.isArray(carriers) ? carriers.length : 'missing'}`, OPTIONS) + + const pageResponse = await context.request('GET', `${BASE}?page=1&size=20`) + const page = context.apiSuccess(MODULE, '查询四期船节点任务分页', pageResponse, '', OPTIONS) + const records = Array.isArray(page?.records) ? page.records : [] + context.assert(MODULE, '船节点分页结构稳定', pageResponse, + Array.isArray(page?.records) && ['page', 'size', 'total', 'pages'].every((field) => number(page?.[field])) + && records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)), + `records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS) + + if (records.length === 0) { + context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse, + Number(page?.total) === 0 && Number(page?.pages) === 0, + `records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS) + return + } + + const queryId = id(records[0]?.query?.id) + const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) + const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS) + const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] + const histories = Array.isArray(detail?.histories) ? detail.histories : [] + context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse, + validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' + && Array.isArray(detail?.nodes) && nodes.every(validNode) + && Array.isArray(detail?.histories) && histories.every(validHistory), + `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, histories=${histories.length}`, OPTIONS) + + const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) + const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) + context.assert(MODULE, '独立历史端点返回可审计操作者与nullable字段', historyResponse, + Array.isArray(history) && history.every(validHistory), + `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs new file mode 100644 index 000000000..1d2af9827 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { run } from './phase4-overseas-business.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8') +function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } +function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } + +function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) { + const calls = []; const steps = []; const gaps = [] + const state = { + truck: null, + looseboard: null, + purchase: { id: '30001', orderNo: 'OP1', customerId: '10001', purchaseWarehouseId: '20001', purchaseCategory: '家居', productName: '置物架', purchaseQuantity: 2, inboundQuantity: 0, inventoryQuantity: 0, status: 'PENDING_REVIEW', version: 0 }, + } + const marker = 'HTTP-20260821-001' + const context = { + marker, calls, steps, gaps, context: {}, + createdResources: ownedPurchase ? { overseasProcurement: { marker, orderNo: 'OP1', customerId: '10001' } } : {}, + async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, path: requestPath, body }); return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) }, + async requestOverseas(method, requestPath, body) { + calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (requestPath.includes('/customer-options')) return response([{ customerId: '10001', customerName: '客户甲' }]) + if (requestPath.includes('/warehouse-options')) return response([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }]) + if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page(ownedPurchase || historicalPurchase ? [state.purchase] : [])) + if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response({ summary: state.purchase, remark: marker }) + if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { state.purchase = { ...state.purchase, ...body, version: state.purchase.version + 1 }; return response({ summary: state.purchase }) } + if (requestPath.includes('/us-truck-orders?') && method === 'POST') { state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }; return response({ summary: state.truck }) } + if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 }; return response({ summary: state.truck }) } + if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response({ summary: state.truck }) + if (requestPath.includes('/us-truck-orders/TK1?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, ...body, version: state.truck.version + 1 }; return response({ summary: state.truck }) } + if (requestPath.includes('/local-looseboard-orders?') && method === 'POST') { state.looseboard = { id: '50001', orderNo: 'LB1', customerId: '10001', status: 'PENDING_ACCEPTANCE', version: 0, ...body }; return response({ summary: state.looseboard }) } + if (requestPath.includes('/local-looseboard-orders/LB1/status?') && method === 'PATCH') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409'); state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 }; return response({ summary: state.looseboard }) } + if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response({ summary: state.looseboard }) + if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409'); state.looseboard = { ...state.looseboard, ...body, version: state.looseboard.version + 1 }; return response({ summary: state.looseboard }) } + throw new Error(`unexpected ${method} ${requestPath}`) + }, + apiSuccess(module, name, result, evidence, options) { const passed = result.http === 200 && String(result.body?.code) === '200'; steps.push({ module, name, passed, evidence, options }); return passed ? result.body.data : null }, + assert(module, name, result, passed, evidence, options) { steps.push({ module, name, passed: Boolean(passed), result, evidence, options }) }, + addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, passed: Boolean(passed), result, evidence, options }) }, + addCoverageGap(...args) { gaps.push(args) }, + } + return context +} + +test('module is context injected and excludes forbidden business surfaces', () => { + assert.match(source, /export async function run\(context\)/) + assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/) + for (const forbidden of ['/merchant/', '/review', '/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) assert.equal(source.includes(forbidden), false, forbidden) + assert.doesNotMatch(source, /requestOverseas\('DELETE'/) +}) + +test('marker truck and looseboard cover create detail edit stale conflict and finally cancel', async () => { + const context = fixture() + await run(context) + assert.equal(context.steps.every((step) => step.passed), true, context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) + assert.deepEqual(context.calls.filter((call) => call.method === 'POST').map((call) => call.path), [ + '/api/platform/v1/last-leg/us-truck-orders?customerId=10001', + '/api/platform/v1/last-leg/local-looseboard-orders?customerId=10001', + ]) + assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/us-truck-orders/')).length, 3) + assert.equal(context.calls.filter((call) => call.method === 'PUT' && call.path.includes('/local-looseboard-orders/')).length, 2) + assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/local-looseboard-orders/')).length, 1) + const statusCalls = context.calls.filter((call) => call.path.includes('/status?')) + assert.deepEqual(statusCalls.map((call) => [call.method, call.path, call.body.status]), [ + ['PATCH', '/api/platform/v1/last-leg/us-truck-orders/TK1/status?customerId=10001', 'CANCELLED'], + ['PATCH', '/api/platform/v1/last-leg/local-looseboard-orders/LB1/status?customerId=10001', 'CANCELLED'], + ]) + assert.ok(statusCalls.every((call) => Number(call.body.version) === 1)) + assert.equal(context.createdResources.phase4OverseasBusiness.truck.marker, context.marker) + assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.marker, context.marker) + assert.equal(context.createdResources.phase4OverseasBusiness.truck.finalStatus, 'CANCELLED') + assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.finalStatus, 'CANCELLED') + assert.equal(context.createdResources.phase4OverseasBusiness.truck.retained, false) + assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.retained, false) + assert.equal(context.gaps.filter((gap) => gap[4]?.includes('retained-marker-test-data')).length, 0) + assert.ok(context.gaps.every((gap) => !/requestId=missing|traceId=missing/.test(gap[3]))) +}) + +test('historical procurement data is never patched without marker ownership', async () => { + const context = fixture({ historicalPurchase: true }) + await run(context) + assert.equal(context.calls.some((call) => call.method === 'PATCH' && call.path.includes('/overseas-procurements/')), false) + assert.equal(context.gaps.some((gap) => gap[4] === 'missing-marker-owned-procurement'), true) +}) + +test('marker-owned procurement may be patched but never created, reviewed or shipped', async () => { + const context = fixture({ ownedPurchase: true }) + await run(context) + const purchaseWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET') + assert.deepEqual(purchaseWrites.map((call) => call.method), ['PATCH']) + assert.match(purchaseWrites[0].body.productSpecification, new RegExp(context.marker)) + assert.equal(context.createdResources.phase4OverseasBusiness.purchase.marker, context.marker) +}) + +test('contract locks real candidates, idempotency, version conflicts and trace evidence', () => { + for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE', '/overseas-procurements/customer-options?limit=200', + '/overseas-procurements/warehouse-options', 'idempotencyKey', 'phase4-truck', 'phase4-looseboard', + '旧 version', '/status', "status: 'CANCELLED'", 'finally', 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs new file mode 100644 index 000000000..c07bcf429 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -0,0 +1,222 @@ +const MODULE = 'phase4-overseas-business' +const SCENARIO = 'phase4-overseas-business-write-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const BASE = '/api/platform/v1/last-leg' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } +function records(page) { return Array.isArray(page?.records) ? page.records : [] } +function query(path, params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) if (value !== '' && value != null) search.set(key, String(value)); return `${path}?${search}` } +function summary(data) { return data?.summary && typeof data.summary === 'object' ? data.summary : data } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response).endsWith('409')) } +function longId(value) { return /^[1-9]\d*$/.test(id(value)) } +function pageValid(page) { return Array.isArray(page?.records) && Number(page?.page) >= 1 && Number(page?.size) >= 1 && Number(page?.total) >= page.records.length && Number(page?.pages) >= 0 } +function coverageGap(context, name, reason, category = 'prerequisite') { context.addCoverageGap(MODULE, SCENARIO, name, reason, category) } +function ownedRegistry(context) { context.createdResources.phase4OverseasBusiness ??= { marker: context.marker }; return context.createdResources.phase4OverseasBusiness } + +async function domesticGet(context, name, path) { + const response = await context.request('GET', path) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +async function overseas(context, method, name, path, body) { + const response = await context.requestOverseas(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +function assertPage(context, name, result) { + context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data), + `records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS) +} + +async function prerequisites(context) { + const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') + assertPage(context, 'ACTIVE 客户候选', tenants) + const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE') + if (!activeCustomers.length) { + coverageGap(context, '海外业务写验收 ACTIVE 客户前置', `IAM 未返回真实 ACTIVE 客户;${trace(tenants.response)}`, 'missing-active-customer') + return null + } + const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`) + const warehouseOptions = await overseas(context, 'GET', '查询 ACTIVE 采购仓候选', `${BASE}/overseas-procurements/warehouse-options`) + context.assert(MODULE, '采购客户候选结构正确', customerOptions.response, + Array.isArray(customerOptions.data) && customerOptions.data.every((item) => longId(item?.customerId) && String(item?.customerName || '').trim()), + `options=${customerOptions.data?.length ?? 'missing'}; ${trace(customerOptions.response)}`, OPTIONS) + context.assert(MODULE, '采购仓候选结构正确', warehouseOptions.response, + Array.isArray(warehouseOptions.data) && warehouseOptions.data.every((item) => longId(item?.warehouseId) && String(item?.warehouseName || '').trim()), + `options=${warehouseOptions.data?.length ?? 'missing'}; ${trace(warehouseOptions.response)}`, OPTIONS) + const activeIds = new Set(activeCustomers.map((item) => id(item.id))) + const procurementCustomer = Array.isArray(customerOptions.data) + ? customerOptions.data.find((item) => activeIds.has(id(item?.customerId))) : null + const warehouse = Array.isArray(warehouseOptions.data) ? warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null + return { activeCustomer: activeCustomers[0], procurementCustomer, warehouse, customerOptions, warehouseOptions } +} + +function markerOwnedPurchase(context) { + const candidates = [context.createdResources?.phase4OverseasBusiness?.purchase, context.createdResources?.overseasProcurement] + return candidates.find((item) => item?.marker === context.marker && id(item?.orderNo) && longId(item?.customerId)) || null +} + +async function procurementFlow(context, prerequisite) { + if (!prerequisite.procurementCustomer || !prerequisite.warehouse) { + coverageGap(context, '采购真实候选与列表前置', `ACTIVE 客户与采购客户候选无交集或无 ACTIVE 采购仓;customerOptions=${prerequisite.customerOptions.data?.length ?? 0}, warehouseOptions=${prerequisite.warehouseOptions.data?.length ?? 0};customer=${trace(prerequisite.customerOptions.response)};warehouse=${trace(prerequisite.warehouseOptions.response)}`, 'missing-procurement-candidate') + return + } + const customerId = id(prerequisite.procurementCustomer.customerId) + const warehouseId = id(prerequisite.warehouse.warehouseId) + const list = await overseas(context, 'GET', '按真实客户与采购仓查询采购订单', query(`${BASE}/overseas-procurements`, { page: 1, size: 20, customerId, purchaseWarehouseId: warehouseId })) + assertPage(context, '海外采购订单', list) + const owned = markerOwnedPurchase(context) + const row = records(list.data).find((item) => owned && item?.orderNo === owned.orderNo && id(item?.customerId) === id(owned.customerId)) + if (!row) { + coverageGap(context, '采购 PATCH 本轮资源', `平台无采购 create,列表中没有 marker=${context.marker} 的本轮自有采购单;禁止修改历史客户数据;${trace(list.response)}`, 'missing-marker-owned-procurement') + return + } + const detailPath = query(`${BASE}/overseas-procurements/${encodeURIComponent(row.orderNo)}`, { customerId: id(row.customerId) }) + const detail = await overseas(context, 'GET', '查询本轮自有采购订单详情', detailPath) + const current = summary(detail.data) + const update = await overseas(context, 'PATCH', '编辑本轮自有采购订单', detailPath, + { version: Number(current?.version), productSpecification: `${context.marker}-procurement`, remark: context.marker }) + const updated = summary(update.data) + context.assert(MODULE, '本轮采购编辑回显 marker 且版本递增', update.response, + updated?.productSpecification === `${context.marker}-procurement` && Number(updated?.version) === Number(current?.version) + 1, + `orderNo=${id(updated?.orderNo) || 'missing'}, version=${updated?.version ?? 'missing'}; ${trace(update.response)}`, OPTIONS) + ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(updated?.orderNo), customerId: id(updated?.customerId), version: Number(updated?.version), retained: true } +} + +function truckPayload(context) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) + return { + transportType: 'PORT_PICKUP', origin: `验收起运-${suffix}`, originAddress: 'Long Beach', + destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', containerType: '40HQ', + palletCount: 1, packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, + currency: 'USD', contactName: '四期验收联系人', contactInfo: '13800000000', + requestedDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), remark: context.marker, + idempotencyKey: `${context.marker}-phase4-truck`.slice(0, 255), + } +} + +async function cancelMarkerOrder(context, resource, base, cancellableStatus, label) { + if (!resource || resource.marker !== context.marker || !resource.orderNo || !longId(resource.customerId)) return + const detailPath = query(`${base}/${encodeURIComponent(resource.orderNo)}`, { customerId: resource.customerId }) + const current = await overseas(context, 'GET', `清理前回查 marker ${label}订单`, detailPath) + const currentSummary = summary(current.data) + if (currentSummary?.status === 'CANCELLED') { + resource.finalStatus = 'CANCELLED'; resource.version = Number(currentSummary.version); resource.retained = false + return + } + if (currentSummary?.status !== cancellableStatus) { + resource.finalStatus = currentSummary?.status || resource.finalStatus; resource.version = Number(currentSummary?.version); resource.retained = true + coverageGap(context, `${label}测试数据最终处置`, `本轮 marker 资源 ${resource.orderNo} 当前状态 ${resource.finalStatus || 'missing'} 不允许安全取消;${trace(current.response)}`, 'unsafe-marker-terminal-state') + return + } + const statusPath = query(`${base}/${encodeURIComponent(resource.orderNo)}/status`, { customerId: resource.customerId }) + const cancelled = await overseas(context, 'PATCH', `取消 marker ${label}订单`, statusPath, + { version: Number(currentSummary.version), status: 'CANCELLED', remark: `${context.marker}-acceptance-cleanup` }) + const cancelledSummary = summary(cancelled.data) + const verified = await overseas(context, 'GET', `回查 marker ${label}订单最终状态`, detailPath) + const finalSummary = summary(verified.data) + const passed = cancelledSummary?.status === 'CANCELLED' && finalSummary?.status === 'CANCELLED' + && Number(finalSummary?.version) === Number(cancelledSummary?.version) + context.assert(MODULE, `${label}本轮测试数据最终取消`, verified.response, passed, + `orderNo=${resource.orderNo}, status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; statusCall=${trace(cancelled.response)}; verify=${trace(verified.response)}`, OPTIONS) + resource.finalStatus = finalSummary?.status || cancelledSummary?.status || resource.finalStatus + resource.version = Number(finalSummary?.version ?? cancelledSummary?.version) + resource.retained = !passed + if (!passed) coverageGap(context, `${label}测试数据最终处置`, `本轮 marker 资源 ${resource.orderNo} 取消失败并保留;statusCall=${trace(cancelled.response)};verify=${trace(verified.response)}`, 'cleanup-failed-retained-marker-test-data') +} + +async function truckFlow(context, customerId) { + const base = `${BASE}/us-truck-orders` + const payload = truckPayload(context) + const created = await overseas(context, 'POST', '创建 marker 本土卡车订单', query(base, { customerId }), payload) + const createdSummary = summary(created.data) + const orderNo = id(createdSummary?.orderNo) + context.assert(MODULE, '卡车创建返回本轮业务键和待审核状态', created.response, + Boolean(orderNo) && id(createdSummary?.customerId) === customerId && createdSummary?.status === 'PENDING_REVIEW' && Number(createdSummary?.version) === 0, + `orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!orderNo) return + const registry = ownedRegistry(context) + registry.truck = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true } + const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId }) + try { + const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path) + const before = summary(detail.data) + const editedRemark = `${context.marker}-truck-edited` + const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path, + { version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: editedRemark }) + const after = summary(edited.data) + context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response, + after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_REVIEW', + `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) + const stale = await context.requestOverseas('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) + context.addStep(MODULE, '卡车旧 version 编辑返回冲突', stale, conflict(stale), trace(stale), OPTIONS) + const final = await overseas(context, 'GET', '冲突后回查 marker 本土卡车订单', path) + const finalSummary = summary(final.data) + context.assert(MODULE, '卡车冲突未造成部分更新', final.response, + finalSummary?.destination === after?.destination && Number(finalSummary?.version) === Number(after?.version), + `destination=${finalSummary?.destination || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS) + registry.truck.version = Number(finalSummary?.version); registry.truck.finalStatus = finalSummary?.status + } finally { + await cancelMarkerOrder(context, registry.truck, base, 'PENDING_REVIEW', '卡车') + } +} + +function looseboardPayload(context) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) + return { + transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', + origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`, + destinationAddress: 'Ontario', containerType: '20GP', packageCount: 3, totalWeightKg: 80, + totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人', + contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10), + remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255), + } +} + +async function looseboardFlow(context, customerId) { + const base = `${BASE}/local-looseboard-orders` + const payload = looseboardPayload(context) + const created = await overseas(context, 'POST', '创建 marker 本土散板订单', query(base, { customerId }), payload) + const createdSummary = summary(created.data) + const orderNo = id(createdSummary?.orderNo) + context.assert(MODULE, '散板创建返回本轮业务键和待接单状态', created.response, + Boolean(orderNo) && id(createdSummary?.customerId) === customerId && createdSummary?.status === 'PENDING_ACCEPTANCE' && Number(createdSummary?.version) === 0, + `orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!orderNo) return + const registry = ownedRegistry(context) + registry.looseboard = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true } + const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId }) + try { + const detail = await overseas(context, 'GET', '查询 marker 本土散板详情', path) + const before = summary(detail.data) + const { idempotencyKey: _key, ...editable } = payload + const edited = await overseas(context, 'PUT', '编辑 marker 本土散板订单', path, + { ...editable, version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-looseboard-edited` }) + const after = summary(edited.data) + context.assert(MODULE, '散板编辑版本递增且普通字段回显', edited.response, + after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_ACCEPTANCE', + `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) + const stale = await context.requestOverseas('PUT', path, + { ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) + context.addStep(MODULE, '散板旧 version 编辑返回冲突', stale, conflict(stale), trace(stale), OPTIONS) + const final = await overseas(context, 'GET', '冲突后回查 marker 本土散板订单', path) + const finalSummary = summary(final.data) + context.assert(MODULE, '散板冲突未造成部分更新', final.response, + finalSummary?.destination === after?.destination && Number(finalSummary?.version) === Number(after?.version), + `destination=${finalSummary?.destination || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS) + registry.looseboard.version = Number(finalSummary?.version); registry.looseboard.finalStatus = finalSummary?.status + } finally { + await cancelMarkerOrder(context, registry.looseboard, base, 'PENDING_ACCEPTANCE', '散板') + } +} + +export async function run(context) { + const prerequisite = await prerequisites(context) + if (!prerequisite) return + await procurementFlow(context, prerequisite) + const customerId = id(prerequisite.activeCustomer.id) + await truckFlow(context, customerId) + await looseboardFlow(context, customerId) +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs new file mode 100644 index 000000000..a83d35208 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { run } from './phase4-overseas-master-data.mjs' + +const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8') +const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin' +const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data' +const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' }) +const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' }) + +function detailQuote(state) { + return { + summary: { ...state.summary }, + warehouseFees: state.warehouseFees.map((item) => ({ ...item })), + transitPrices: state.transitPrices.map((item) => ({ ...item })), + createdBy: 'admin', createdAt: '2026-08-21T00:00:00', + } +} + +function vehicleWire(state) { + return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) } +} + +function fakeContext() { + const calls = [] + const coverageGaps = [] + const marker = 'ADMIN-API-20260821123456789' + const state = { warehouse: null, quote: null, exchange: null, vehicle: null } + const context = { + marker, calls, coverageGaps, createdResources: {}, + apiSuccess(_module, name, response) { + assert.equal(response?.http, 200, `${name}: HTTP`) + assert.equal(String(response?.body?.code), '200', `${name}: code`) + return response.body.data + }, + assert(_module, name, _response, condition, evidence) { + assert.equal(Boolean(condition), true, `${name}: ${evidence}`) + return true + }, + expectConflict(_module, name, response) { + assert.equal(response.http, 200, `${name}: HTTP`) + assert.ok(['A0409', '409'].includes(String(response.body?.code)), `${name}: code=${response.body?.code}`) + return true + }, + addCoverageGap(module, scenario, name, reason, category, metadata = {}) { + coverageGaps.push({ module, scenario, name, reason, category, ...metadata }) + }, + async request(method, path, body) { + calls.push({ gateway: 'domestic', method, path, body }) + assert.notEqual(method, 'DELETE') + if (method === 'POST' && path === DOMESTIC_BASE) { + state.warehouse = { ...body, id: '910000000000000001' } + return ok(state.warehouse.id) + } + if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) { + return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities }) + } + if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) { + state.warehouse = { ...body, id: state.warehouse.id } + return ok(null) + } + if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) { + state.warehouse.status = 'INACTIVE' + return ok(null) + } + assert.fail(`unexpected domestic call ${method} ${path}`) + }, + async requestOverseas(method, path, body) { + calls.push({ gateway: 'overseas', method, path, body }) + assert.notEqual(method, 'DELETE') + if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) { + return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, + warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) + } + if (method === 'POST' && path === `${OVERSEAS_BASE}/overseas-quotes`) { + state.quote = { + summary: { id: '920000000000000001', ...body, warehouseCode: state.warehouse.warehouseCode, + warehouseName: state.warehouse.warehouseName, countryCode: 'US', status: 'INACTIVE', recordVersion: 0 }, + warehouseFees: body.warehouseFees, transitPrices: body.transitPrices || [], + } + return ok(detailQuote(state.quote)) + } + if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) { + if (method === 'GET') return ok(detailQuote(state.quote)) + if (method === 'PUT') { + if (Number(body.recordVersion) !== state.quote.summary.recordVersion) return conflict() + state.quote.summary = { ...state.quote.summary, name: body.name, providerName: body.providerName, + currency: body.currency, effectiveStart: body.effectiveStart, effectiveEnd: body.effectiveEnd, + remark: body.remark, recordVersion: state.quote.summary.recordVersion + 1 } + state.quote.warehouseFees = body.warehouseFees + state.quote.transitPrices = body.transitPrices || [] + return ok(detailQuote(state.quote)) + } + } + if (method === 'PATCH' && path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001/status`) { + assert.equal(Number(body.recordVersion), state.quote.summary.recordVersion) + state.quote.summary.status = body.status + state.quote.summary.recordVersion += 1 + return ok(detailQuote(state.quote)) + } + if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) { + return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + } + if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) { + state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 } + return ok({ ...state.exchange }) + } + if (state.exchange && path === `${OVERSEAS_BASE}/exchange-rates/${state.exchange.month}`) { + if (method === 'GET') return ok({ ...state.exchange }) + if (method === 'PUT') { + if (Number(body.recordVersion) !== state.exchange.recordVersion) return conflict() + state.exchange = { ...state.exchange, usdToCnyAverage: body.usdToCnyAverage, + remark: body.remark, recordVersion: state.exchange.recordVersion + 1 } + return ok({ ...state.exchange }) + } + } + if (state.exchange && method === 'PATCH' && path === `${OVERSEAS_BASE}/exchange-rates/${state.exchange.month}/status`) { + assert.equal(Number(body.recordVersion), state.exchange.recordVersion) + state.exchange = { ...state.exchange, status: body.status, remark: body.remark ?? state.exchange.remark, + recordVersion: state.exchange.recordVersion + 1 } + return ok({ ...state.exchange }) + } + if (method === 'POST' && path === `${OVERSEAS_BASE}/vehicles`) { + state.vehicle = { id: '930000000000000001', ...body, recordVersion: 0 } + return ok(vehicleWire(state)) + } + if (path === `${OVERSEAS_BASE}/vehicles/930000000000000001`) { + if (method === 'GET') return ok(vehicleWire(state)) + if (method === 'PUT') { + if (Number(body.recordVersion) !== state.vehicle.recordVersion) return conflict() + state.vehicle = { ...state.vehicle, ...body, recordVersion: state.vehicle.recordVersion + 1, + status: state.vehicle.status } + return ok(vehicleWire(state)) + } + } + if (method === 'PATCH' && path === `${OVERSEAS_BASE}/vehicles/930000000000000001/status`) { + assert.equal(Number(body.recordVersion), state.vehicle.recordVersion) + state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 } + return ok(vehicleWire(state)) + } + assert.fail(`unexpected overseas call ${method} ${path}`) + }, + async requestOverseasBinary(method, path) { + calls.push({ gateway: 'overseas-binary', method, path }) + assert.equal(method, 'GET') + assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) + return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'), + contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' } + }, + } + return { context, state } +} + +test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { + assert.match(source, /context\.request\('POST', DOMESTIC_BASE/) + assert.match(source, /warehouseType: 'OVERSEAS_PARTNER'/) + assert.match(source, /context\.requestOverseas\('POST'/) + assert.match(source, /context\.requestOverseas\('PUT'/) + assert.match(source, /context\.requestOverseas\('PATCH'/) + assert.match(source, /context\.requestOverseasBinary\('GET'/) + assert.match(source, /context\.expectConflict/) + assert.match(source, /finally\s*\{/) + assert.match(source, /context\.createdResources\.phase4OverseasMasterData/) + assert.match(source, /context\.addCoverageGap/) + assert.match(source, /2090 \+ Math\.floor/) + assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/) + for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export', + 'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint)) +}) + +test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => { + const { context, state } = fakeContext() + await run(context) + const resource = context.createdResources.phase4OverseasMasterData + assert.equal(resource.partnerWarehouse.id, '910000000000000001') + assert.equal(resource.partnerWarehouse.finalStatus, 'INACTIVE') + assert.equal(resource.quote.id, '920000000000000001') + assert.equal(resource.quote.finalStatus, 'INACTIVE') + assert.match(resource.exchangeRate.month, /^209\d-(0[1-9]|1[0-2])$/) + assert.equal(resource.exchangeRate.finalStatus, 'INACTIVE') + assert.equal(resource.vehicle.id, '930000000000000001') + assert.equal(resource.vehicle.finalStatus, 'INACTIVE') + assert.equal(typeof resource.vehicle.recordVersion, 'number') + assert.equal(state.warehouse.status, 'INACTIVE') + assert.equal(state.quote.summary.status, 'INACTIVE') + assert.equal(state.exchange.status, 'INACTIVE') + assert.equal(state.vehicle.status, 'INACTIVE') + assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3) + assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0) + assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7) +}) + +test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => { + const { context, state } = fakeContext() + const original = context.requestOverseas + context.requestOverseas = async (method, path, body) => { + if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([]) + return original(method, path, body) + } + await run(context) + assert.equal(state.warehouse.status, 'INACTIVE') + assert.equal(state.quote, null) + assert.equal(state.exchange.status, 'INACTIVE') + assert.equal(state.vehicle.status, 'INACTIVE') + assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true) + assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE') + assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE') + assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE') + assert.equal(context.coverageGaps.length, 1) + assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期') + assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite') + assert.match(context.coverageGaps[0].reason, /30分钟空缓存/) + assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs new file mode 100644 index 000000000..77acae128 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -0,0 +1,375 @@ +const MODULE = 'phase4-overseas-master-data' +const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin' +const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data' +const BLOCKER = { scenario: 'phase4-overseas-master-data-lifecycle', deploymentBlocker: true } + +const success = (response) => response?.http === 200 && String(response?.body?.code) === '200' +const data = (response) => success(response) ? response.body.data : null +const recordVersion = (value) => Number.isInteger(Number(value)) && Number(value) >= 0 +const asId = (value) => value == null ? '' : String(value) + +function requireData(context, name, response) { + const value = context.apiSuccess(MODULE, name, response, '', BLOCKER) + if (value == null) throw new Error(`${name}失败,生命周期无法继续`) + return value +} + +function assertBinaryCsv(context, name, response) { + context.assert(MODULE, name, response, + response?.http === 200 + && String(response.contentType || '').toLowerCase().startsWith('text/csv') + && response.bytes instanceof Uint8Array && response.bytes.length > 0, + `HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`, + BLOCKER) +} + +async function safeCleanup(context, name, action) { + try { + await action() + } catch (error) { + context.assert(MODULE, `${name} finally停用`, null, false, + error instanceof Error ? error.message : String(error), BLOCKER) + } +} + +function warehousePayload(marker, edited = false) { + const suffix = marker.replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + return { + tenantId: 0, + warehouseCode: `P4-${suffix}`.slice(0, 50), + warehouseName: edited ? `${marker} 合作仓-已编辑` : `${marker} 合作仓`, + warehouseType: 'OVERSEAS_PARTNER', + countryCode: 'US', + timezone: 'UTC-8', + contactName: 'Phase4 QA', + contactPhone: '+1-213-555-0199', + contactEmail: null, + status: 'ACTIVE', + remark: edited ? `${marker} lifecycle edited` : `${marker} lifecycle`, + address: { + addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', + city: 'Los Angeles', district: 'Commerce', postalCode: '90040', + addressLine1: edited ? '402 Phase Four Test Avenue' : '401 Phase Four Test Avenue', + addressLine2: null, latitude: null, longitude: null, + }, + capabilities: [{ + capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, + cutoffTime: null, description: `${marker} acceptance capability`, + }], + } +} + +async function createAndVerifyPartnerWarehouse(context, resource) { + const initial = warehousePayload(context.marker) + const createResponse = await context.request('POST', DOMESTIC_BASE, initial) + const createdId = asId(requireData(context, '新增OVERSEAS_PARTNER合作仓', createResponse)) + if (!createdId) throw new Error('合作仓创建响应未返回ID') + resource.partnerWarehouse = { id: createdId, code: initial.warehouseCode, finalStatus: 'ACTIVE' } + + const detailPath = `${DOMESTIC_BASE}/${encodeURIComponent(createdId)}?tenantId=0` + const detailResponse = await context.request('GET', detailPath) + const detail = requireData(context, '查询合作仓详情', detailResponse) + context.assert(MODULE, '合作仓真实类型与marker', detailResponse, + detail?.warehouse?.warehouseType === 'OVERSEAS_PARTNER' + && detail.warehouse.warehouseCode === initial.warehouseCode, + `id=${createdId}, type=${detail?.warehouse?.warehouseType || 'missing'}, code=${detail?.warehouse?.warehouseCode || 'missing'}`, + BLOCKER) + + const edited = warehousePayload(context.marker, true) + const editResponse = await context.request('PUT', detailPath, edited) + context.apiSuccess(MODULE, '编辑合作仓(当前契约无version)', editResponse, 'warehouse SaveRequest 无乐观锁字段', BLOCKER) + const afterResponse = await context.request('GET', detailPath) + const after = requireData(context, '编辑后查询合作仓详情', afterResponse) + context.assert(MODULE, '合作仓编辑持久化', afterResponse, + after?.warehouse?.warehouseName === edited.warehouseName + && after?.warehouse?.remark === edited.remark + && after?.warehouse?.warehouseType === 'OVERSEAS_PARTNER', + `name=${after?.warehouse?.warehouseName || 'missing'}, type=${after?.warehouse?.warehouseType || 'missing'}`, + BLOCKER) +} + +function quoteCreatePayload(context, warehouseId) { + return { + name: `${context.marker} 海外报价`, warehouseId, + providerName: `${context.marker} Provider`, currency: 'USD', + effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31', + versionCode: `${context.marker}-V1`.slice(0, 64), + warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: context.marker }], + transitPrices: [{ fbaWarehouseCode: 'ONT8', deliveryMethod: 'TRUCK', estimatedMiles: 12.5, price: 18.75, minimumCharge: 20, remark: context.marker }], + remark: `${context.marker} lifecycle`, + } +} + +function quoteUpdatePayload(created, context, version) { + return { + recordVersion: version, name: `${context.marker} 海外报价-已编辑`, + providerName: created.providerName, currency: created.currency, + effectiveStart: created.effectiveStart, effectiveEnd: created.effectiveEnd, + warehouseFees: [{ item: 'Storage QA Edited', billingUnit: 'PALLET', unitPrice: 1.5, minimumCharge: 3, remark: `${context.marker} edited` }], + transitPrices: created.transitPrices, + remark: `${context.marker} lifecycle edited`, + } +} + +async function quoteLifecycle(context, resource) { + const optionsResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const options = requireData(context, '查询海外报价合作仓候选', optionsResponse) + context.assert(MODULE, '报价候选只来自海外正式接口', optionsResponse, + Array.isArray(options) && options.every((item) => asId(item.warehouseId) + && item.warehouseCode && item.warehouseName && item.countryCode), + `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) + if (Array.isArray(options) && options.length === 0) { + resource.quote = { + skipped: true, + reason: 'warehouse-options-empty-cache', + finalStatus: null, + } + context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期', + 'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。', + 'deployment-prerequisite', { + marker: context.marker, + partnerWarehouseId: resource.partnerWarehouse?.id || null, + http: optionsResponse?.http ?? null, + code: optionsResponse?.body?.code ?? null, + requestId: optionsResponse?.requestId ?? null, + traceId: optionsResponse?.traceId ?? null, + }) + return false + } + const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id) + const option = createdOption || options[0] + if (!option) throw new Error('海外报价接口未返回可用合作仓候选') + + const payload = quoteCreatePayload(context, asId(option.warehouseId)) + const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload) + let detail = requireData(context, '新增海外报价', createResponse) + const id = asId(detail?.summary?.id) + if (!id || !recordVersion(detail?.summary?.recordVersion)) throw new Error('报价创建响应缺少字符串ID或recordVersion') + resource.quote = { id, warehouseId: payload.warehouseId, recordVersion: Number(detail.summary.recordVersion), finalStatus: detail.summary.status } + const path = `${OVERSEAS_BASE}/overseas-quotes/${encodeURIComponent(id)}` + + const detailResponse = await context.requestOverseas('GET', path) + detail = requireData(context, '查询海外报价详情', detailResponse) + context.assert(MODULE, '报价详情长ID、费用和版本', detailResponse, + asId(detail?.summary?.id) === id && recordVersion(detail?.summary?.recordVersion) + && Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0, + `id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER) + + const staleVersion = Number(detail.summary.recordVersion) + const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion) + const updateResponse = await context.requestOverseas('PUT', path, updatePayload) + detail = requireData(context, '编辑海外报价', updateResponse) + resource.quote.recordVersion = Number(detail.summary.recordVersion) + context.assert(MODULE, '报价编辑版本递增', updateResponse, + detail.summary.name === updatePayload.name && resource.quote.recordVersion === staleVersion + 1, + `before=${staleVersion}, after=${resource.quote.recordVersion}`, BLOCKER) + + const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, name: `${context.marker} stale` }) + context.expectConflict(MODULE, '报价旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario) + const afterConflictResponse = await context.requestOverseas('GET', path) + detail = requireData(context, '报价冲突后重新查询', afterConflictResponse) + context.assert(MODULE, '报价冲突未覆盖新值', afterConflictResponse, + detail.summary.name === updatePayload.name && Number(detail.summary.recordVersion) === resource.quote.recordVersion, + `name=${detail.summary.name}, version=${detail.summary.recordVersion}`, BLOCKER) + + const activateResponse = await context.requestOverseas('PATCH', `${path}/status`, { + status: 'ACTIVE', recordVersion: resource.quote.recordVersion, + }) + detail = requireData(context, '启用海外报价', activateResponse) + resource.quote.recordVersion = Number(detail.summary.recordVersion) + resource.quote.finalStatus = detail.summary.status + context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse, + detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER) + + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) + assertBinaryCsv(context, '导出海外报价CSV', csv) + return true +} + +async function chooseSafeExchangeMonth(context) { + const digits = context.marker.replace(/\D/g, '') + const seed = Number(digits.slice(-4) || '0') + for (let offset = 0; offset < 24; offset += 1) { + const serial = ((seed + offset) % 120) + const month = `${2090 + Math.floor(serial / 12)}-${String((serial % 12) + 1).padStart(2, '0')}` + const path = `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}` + const response = await context.requestOverseas('GET', path) + const page = requireData(context, `探测安全汇率月份${month}`, response) + if (Array.isArray(page?.records) && page.records.length === 0 && Number(page.total) === 0) return month + } + throw new Error('2090-2099安全月份池均已占用,拒绝覆盖既有汇率') +} + +async function exchangeLifecycle(context, resource) { + const month = await chooseSafeExchangeMonth(context) + const createPayload = { month, usdToCnyAverage: 7.123456, remark: `${context.marker} safe marker month` } + const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload) + let rate = requireData(context, '新增安全marker月汇率', createResponse) + resource.exchangeRate = { month, recordVersion: Number(rate.recordVersion), finalStatus: rate.status } + const path = `${OVERSEAS_BASE}/exchange-rates/${encodeURIComponent(month)}` + + const detailResponse = await context.requestOverseas('GET', path) + rate = requireData(context, '查询marker月汇率详情', detailResponse) + context.assert(MODULE, '汇率详情月份、decimal与版本', detailResponse, + rate.month === month && Number(rate.usdToCnyAverage) > 0 && recordVersion(rate.recordVersion), + `month=${rate.month}, rate=${rate.usdToCnyAverage}, version=${rate.recordVersion}`, BLOCKER) + + const staleVersion = Number(rate.recordVersion) + const updatePayload = { recordVersion: staleVersion, usdToCnyAverage: 7.234567, remark: `${context.marker} overwritten once` } + const updateResponse = await context.requestOverseas('PUT', path, updatePayload) + rate = requireData(context, '覆盖维护marker月汇率', updateResponse) + resource.exchangeRate.recordVersion = Number(rate.recordVersion) + context.assert(MODULE, '汇率覆盖版本递增', updateResponse, + Number(rate.usdToCnyAverage) === updatePayload.usdToCnyAverage + && resource.exchangeRate.recordVersion === staleVersion + 1, + `before=${staleVersion}, after=${resource.exchangeRate.recordVersion}`, BLOCKER) + + const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 }) + context.expectConflict(MODULE, '汇率旧recordVersion覆盖冲突', conflictResponse, BLOCKER.scenario) + const afterConflictResponse = await context.requestOverseas('GET', path) + rate = requireData(context, '汇率冲突后重新查询', afterConflictResponse) + context.assert(MODULE, '汇率冲突未覆盖新值', afterConflictResponse, + Number(rate.usdToCnyAverage) === updatePayload.usdToCnyAverage + && Number(rate.recordVersion) === resource.exchangeRate.recordVersion, + `rate=${rate.usdToCnyAverage}, version=${rate.recordVersion}`, BLOCKER) + + const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, { + status: 'INACTIVE', recordVersion: resource.exchangeRate.recordVersion, + remark: `${context.marker} lifecycle complete`, + }) + rate = requireData(context, '停用marker月汇率', statusResponse) + resource.exchangeRate.recordVersion = Number(rate.recordVersion) + resource.exchangeRate.finalStatus = rate.status + context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse, + rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER) + + const csv = await context.requestOverseasBinary('GET', + `${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`) + assertBinaryCsv(context, '导出marker月汇率CSV', csv) +} + +function vehiclePayload(context, edited = false) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + return { + plateNumber: `QA-${suffix}`.slice(0, 64), + model: edited ? 'Phase4 QA Truck Edited' : 'Phase4 QA Truck', + fuelConsumptionPer100Km: edited ? 18.75 : 18.5, + mileageKm: edited ? 101 : 100, + driverOrDispatchCompany: `${context.marker} Carrier`, phone: '+1-213-555-0188', + region: 'OVERSEAS', ownership: 'OWNED', + ...(edited ? {} : { status: 'IN_SERVICE' }), + remark: edited ? `${context.marker} lifecycle edited` : `${context.marker} lifecycle`, + } +} + +async function vehicleLifecycle(context, resource) { + const createPayload = vehiclePayload(context) + const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/vehicles`, createPayload) + let vehicle = requireData(context, '新增marker车辆', createResponse) + const id = asId(vehicle.id) + if (!id || !recordVersion(vehicle.recordVersion)) throw new Error('车辆创建响应缺少字符串ID或recordVersion') + resource.vehicle = { id, plateNumber: createPayload.plateNumber, recordVersion: Number(vehicle.recordVersion), finalStatus: vehicle.status } + const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(id)}` + + const detailResponse = await context.requestOverseas('GET', path) + vehicle = requireData(context, '查询marker车辆详情', detailResponse) + context.assert(MODULE, '车辆详情长ID、五态字段与版本', detailResponse, + asId(vehicle.id) === id && ['IN_SERVICE', 'DISPATCHED', 'MAINTENANCE', 'INACTIVE', 'RETURNED'].includes(vehicle.status) + && recordVersion(vehicle.recordVersion), + `id=${asId(vehicle.id)}, status=${vehicle.status}, version=${vehicle.recordVersion}`, BLOCKER) + + const staleVersion = Number(vehicle.recordVersion) + const updatePayload = { recordVersion: staleVersion, ...vehiclePayload(context, true) } + const updateResponse = await context.requestOverseas('PUT', path, updatePayload) + vehicle = requireData(context, '编辑marker车辆', updateResponse) + const updatedVersion = Number(vehicle.recordVersion) + const updatedMileage = Number(vehicle.mileageKm) + resource.vehicle.recordVersion = updatedVersion + context.assert(MODULE, '车辆编辑版本递增', updateResponse, + vehicle.model === updatePayload.model && updatedMileage === Number(updatePayload.mileageKm) + && updatedVersion === Number(staleVersion) + 1, + `before=${staleVersion}, after=${updatedVersion}, mileage=${updatedMileage}`, BLOCKER) + + const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, model: 'stale overwrite' }) + context.expectConflict(MODULE, '车辆旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario) + const afterConflictResponse = await context.requestOverseas('GET', path) + vehicle = requireData(context, '车辆冲突后重新查询', afterConflictResponse) + context.assert(MODULE, '车辆冲突未覆盖新值', afterConflictResponse, + vehicle.model === updatePayload.model && Number(vehicle.recordVersion) === resource.vehicle.recordVersion, + `model=${vehicle.model}, version=${vehicle.recordVersion}`, BLOCKER) + + const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, { + status: 'MAINTENANCE', recordVersion: resource.vehicle.recordVersion, + }) + vehicle = requireData(context, '车辆切换安全维护状态', statusResponse) + resource.vehicle.recordVersion = Number(vehicle.recordVersion) + resource.vehicle.finalStatus = vehicle.status + context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse, + vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER) + + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) + assertBinaryCsv(context, '导出marker车辆CSV', csv) +} + +async function cleanupPartnerWarehouse(context, resource) { + if (!resource.partnerWarehouse?.id) return + const response = await context.request('PATCH', + `${DOMESTIC_BASE}/${encodeURIComponent(resource.partnerWarehouse.id)}/status?tenantId=0&status=INACTIVE`) + context.apiSuccess(MODULE, '合作仓finally停用', response, `id=${resource.partnerWarehouse.id}`, BLOCKER) + if (success(response)) resource.partnerWarehouse.finalStatus = 'INACTIVE' +} + +async function cleanupVersioned(context, resource, key, path, extract) { + const current = resource[key] + if (!current) return + const detailResponse = await context.requestOverseas('GET', path) + const detail = data(detailResponse) + const value = extract(detail) + if (!success(detailResponse) || !value || !recordVersion(value.recordVersion)) { + context.assert(MODULE, `${key} finally查询当前版本`, detailResponse, false, + `HTTP=${detailResponse?.http ?? 0}, code=${detailResponse?.body?.code || 'missing'}`, BLOCKER) + return + } + current.recordVersion = Number(value.recordVersion) + current.finalStatus = value.status + if (value.status === 'INACTIVE') return + const response = await context.requestOverseas('PATCH', `${path}/status`, { + status: 'INACTIVE', recordVersion: current.recordVersion, + ...(key === 'exchangeRate' ? { remark: `${context.marker} finally cleanup` } : {}), + }) + const stopped = data(response) + const normalized = extract(stopped) + context.apiSuccess(MODULE, `${key} finally停用`, response, `path=${path}`, BLOCKER) + if (normalized) { + current.recordVersion = Number(normalized.recordVersion) + current.finalStatus = normalized.status + } +} + +export async function run(context) { + const resource = { marker: context.marker } + context.createdResources.phase4OverseasMasterData = resource + try { + await createAndVerifyPartnerWarehouse(context, resource) + await quoteLifecycle(context, resource) + await exchangeLifecycle(context, resource) + await vehicleLifecycle(context, resource) + } finally { + await safeCleanup(context, '海外报价', async () => { + if (!resource.quote?.id) return + const path = `${OVERSEAS_BASE}/overseas-quotes/${encodeURIComponent(resource.quote.id)}` + await cleanupVersioned(context, resource, 'quote', path, (value) => value?.summary) + }) + await safeCleanup(context, '月度汇率', async () => { + if (!resource.exchangeRate?.month) return + const path = `${OVERSEAS_BASE}/exchange-rates/${encodeURIComponent(resource.exchangeRate.month)}` + await cleanupVersioned(context, resource, 'exchangeRate', path, (value) => value) + }) + await safeCleanup(context, '车辆', async () => { + if (!resource.vehicle?.id) return + const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(resource.vehicle.id)}` + await cleanupVersioned(context, resource, 'vehicle', path, (value) => value) + }) + await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource)) + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs new file mode 100644 index 000000000..df3577739 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { run } from './phase4-overseas-warehouse.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8') + +function response(data) { return { http: 200, body: { code: '200', message: 'success', data }, requestId: 'request-test', traceId: 'trace-test' } } +function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } +function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { + const calls = []; const steps = []; const gaps = []; let createdPallet = null + const context = { + context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, + async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, + async requestOverseas(method, requestPath, body) { + calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', ...body }; return response('71001') } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { createdPallet = { ...createdPallet, ...body }; return response(null) } + if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) + if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) + if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) + if (requestPath.includes('/relabel-applications/RL-1?')) return response({ application: { relabelNo: 'RL-1' }, items: [], history: [] }) + if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ id: '50001', sourceOrderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) + if (requestPath.includes('/inbound-plans/IP-1?')) return response({ planNo: 'IP-1', sourceOrderId: '30001', lines: [], pallets: [], allocations: [] }) + if (requestPath.includes('/inventory/summary?')) return response({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 }) + if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) + if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) + if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) + if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) + if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001' } }) + if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) + if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) + if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) + throw new Error(`Unexpected path: ${requestPath}`) + }, + async requestOverseasBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } }, + apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null }, + assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, + addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, + addCoverageGap(...args) { gaps.push(args) }, + } + return context +} + +test('模块只依赖注入请求,不读取环境或自行联网', () => { + assert.match(source, /export async function run\(context\)/) + assert.match(source, /context\.requestOverseas\('GET'/) + assert.match(source, /context\.requestOverseasBinary\('GET'/) + assert.doesNotMatch(source, /https?:\/\//) + assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) +}) + +test('仅托盘生命周期允许写请求,其他海外业务保持只读', () => { + assert.doesNotMatch(source, /requestOverseas\(['"]PATCH['"]/) + const context = contextFixture() + return run(context).then(() => { + assert.ok(context.calls.length >= 12) + assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT'].includes(call.method))) + assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) + assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) + const writes = context.calls.filter((call) => call.method !== 'GET') + assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT']) + assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/pallets'))) + assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) + }) +}) + +test('覆盖冻结的海外只读列表与有记录才查详情', async () => { + const context = contextFixture() + await run(context) + for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/pallets?']) { + assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) + } + for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) { + assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) + } + assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001' }) + assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED' }) +}) + +test('缺少真实客户或仓库时记录 coverageGap,不使用固定业务ID补洞', async () => { + const noCustomer = contextFixture({ tenants: [] }) + await run(noCustomer) + assert.equal(noCustomer.gaps.length, 1) + assert.match(noCustomer.gaps[0][3], /requestId=request-test.*traceId=trace-test/) + assert.equal(noCustomer.calls.filter((call) => call.channel === 'overseas').length, 0) + + const noWarehouse = contextFixture({ warehouseRecords: [] }) + await run(noWarehouse) + assert.equal(noWarehouse.gaps.some((gap) => gap[2] === '公共托盘真实仓库前置'), true) + assert.equal(noWarehouse.calls.some((call) => call.path.includes('/pallets')), false) +}) + +test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => { + assert.match(source, /inventory\/balances\/export/) + assert.match(source, /inventory\/ledger\/export/) + assert.match(source, /inventory\/summary[^\n]+customerId, page: 1, size: 200/) + assert.match(source, /inventory\/balances\/export[^\n]+customerId, page: 1, size: 200/) + assert.match(source, /inventory\/ledger\/export[^\n]+customerId, page: 1, size: 200/) + for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /requestId=/) + assert.match(source, /traceId=/) + assert.match(source, /longId\(plan\.sourceOrderId\)/) + assert.match(source, /id\(pallet\.customerId\) === '0'/) + assert.match(source, /换标创建与状态生命周期/) + assert.match(source, /旧版本并发冲突/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs new file mode 100644 index 000000000..292fb489b --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -0,0 +1,242 @@ +const MODULE = 'phase4-overseas-warehouse' +const SCENARIO = 'phase4-overseas-warehouse' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } +function nonNegative(value) { return Number.isFinite(Number(value)) && Number(value) >= 0 } +function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, params) { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) + return `${path}?${search}` +} +function records(page) { return Array.isArray(page?.records) ? page.records : [] } +function pageValid(page) { + return Array.isArray(page?.records) && Number.isInteger(Number(page?.page)) && Number(page.page) >= 1 + && Number.isInteger(Number(page?.size)) && Number(page.size) >= 1 + && nonNegative(page?.total) && nonNegative(page?.pages) +} +function assertEnvelope(context, name, response) { + context.assert(MODULE, `${name}响应包络正确`, response, + response?.http === 200 && String(response?.body?.code) === '200' && Object.hasOwn(response?.body || {}, 'data'), + trace(response), OPTIONS) +} +async function domesticGet(context, name, path) { + const response = await context.request('GET', path) + assertEnvelope(context, name, response) + return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } +} +async function overseasGet(context, name, path) { + const response = await context.requestOverseas('GET', path) + assertEnvelope(context, name, response) + return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } +} +async function overseasMutation(context, method, name, path, body) { + const response = await context.requestOverseas(method, path, body) + assertEnvelope(context, name, response) + return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } +} +async function overseasCsv(context, name, path) { + const response = await context.requestOverseasBinary('GET', path) + context.addStep(MODULE, name, response, + response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'), + `bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS) + return response +} +function assertPage(context, name, result) { + context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data), + `records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS) +} +function coverageGap(context, name, reason, category = 'prerequisite') { + context.addCoverageGap(MODULE, SCENARIO, name, reason, category) +} + +export async function run(context) { + context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null } + const tenantsResult = await domesticGet(context, '查询可用客户候选', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') + assertPage(context, '可用客户候选', tenantsResult) + const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') + const customerId = id(customer?.id) + if (!customerId) { + coverageGap(context, '海外只读接口显式客户范围', `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`) + return + } + + const scoped = { customerId, page: 1, size: 20 } + const orders = await overseasGet(context, '查询尾程订单列表', query('/api/platform/v1/last-leg/orders', scoped)) + assertPage(context, '尾程订单列表', orders) + const order = records(orders.data)[0] + if (order) { + context.assert(MODULE, '尾程订单关键字段正确', orders.response, + longId(order.id) && nonBlank(order.orderNo) && nonBlank(order.currentStatus), + `id=${id(order.id) || 'missing'}, orderNo=${order.orderNo || 'missing'}, status=${order.currentStatus || 'missing'}; ${trace(orders.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询尾程订单详情', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}`, { customerId })) + context.assert(MODULE, '尾程订单详情对应列表记录', detail.response, + id(detail.data?.order?.id) === id(order.id) && Array.isArray(detail.data?.cargo) && Array.isArray(detail.data?.statusHistory), + `listId=${id(order.id)}, detailId=${id(detail.data?.order?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } + + const relabels = await overseasGet(context, '查询换标单列表', query('/api/platform/v1/last-leg/relabel-applications', scoped)) + assertPage(context, '换标单列表', relabels) + const relabel = records(relabels.data)[0] + if (relabel) { + context.assert(MODULE, '换标单关键字段正确', relabels.response, + longId(relabel.id) && nonBlank(relabel.relabelNo) && nonBlank(relabel.status), + `id=${id(relabel.id) || 'missing'}, relabelNo=${relabel.relabelNo || 'missing'}, status=${relabel.status || 'missing'}; ${trace(relabels.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询换标单详情', query(`/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabel.relabelNo)}`, { customerId })) + context.assert(MODULE, '换标单详情对应列表记录', detail.response, + detail.data?.application?.relabelNo === relabel.relabelNo && Array.isArray(detail.data?.items) && Array.isArray(detail.data?.history), + `listNo=${relabel.relabelNo}, detailNo=${detail.data?.application?.relabelNo || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } + + const plans = await overseasGet(context, '查询入仓计划列表', query('/api/platform/v1/last-leg/inbound-plans', scoped)) + assertPage(context, '入仓计划列表', plans) + const plan = records(plans.data)[0] + if (plan) { + context.assert(MODULE, '入仓计划关键字段正确', plans.response, + longId(plan.id) && longId(plan.sourceOrderId) && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status), + `id=${id(plan.id) || 'missing'}, sourceOrderId=${id(plan.sourceOrderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询入仓计划详情', query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}`, { customerId })) + context.assert(MODULE, '入仓计划详情对应列表记录', detail.response, + detail.data?.planNo === plan.planNo && longId(detail.data?.sourceOrderId) + && Array.isArray(detail.data?.lines) && Array.isArray(detail.data?.pallets) && Array.isArray(detail.data?.allocations), + `listNo=${plan.planNo}, detailNo=${detail.data?.planNo || 'missing'}, sourceOrderId=${id(detail.data?.sourceOrderId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } + + const summary = await overseasGet(context, '查询库存汇总', query('/api/platform/v1/last-leg/inventory/summary', { customerId, page: 1, size: 200 })) + context.assert(MODULE, '库存汇总关键数量非负', summary.response, + ['inboundOrderCount', 'palletCount', 'totalPackages', 'availablePackages', 'holdPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages'] + .every((field) => nonNegative(summary.data?.[field])), + `total=${summary.data?.totalPackages ?? 'missing'}, available=${summary.data?.availablePackages ?? 'missing'}; ${trace(summary.response)}`, OPTIONS) + + const balances = await overseasGet(context, '查询SKU库存余额', query('/api/platform/v1/last-leg/inventory/balances', scoped)) + assertPage(context, 'SKU库存余额', balances) + const balance = records(balances.data)[0] + if (balance) { + context.assert(MODULE, 'SKU库存余额关键字段正确', balances.response, + longId(balance.id) && id(balance.customerId) === customerId && nonBlank(balance.skuCode) + && nonNegative(balance.totalPackages) && nonNegative(balance.availablePackages) && nonBlank(balance.updatedAt), + `id=${id(balance.id) || 'missing'}, sku=${balance.skuCode || 'missing'}, total=${balance.totalPackages ?? 'missing'}; ${trace(balances.response)}`, OPTIONS) + const positions = await overseasGet(context, '查询库存库位详情', query(`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId })) + context.assert(MODULE, '库存库位详情结构正确', positions.response, + Array.isArray(positions.data) && positions.data.every((item) => longId(item?.id) && id(item?.balanceId) === id(balance.id) && nonNegative(item?.totalPackages)), + `balanceId=${id(balance.id)}, positions=${positions.data?.length ?? 'missing'}; ${trace(positions.response)}`, OPTIONS) + } + + const ledger = await overseasGet(context, '查询进销存流水', query('/api/platform/v1/last-leg/inventory/ledger', scoped)) + assertPage(context, '进销存流水', ledger) + const ledgerRow = records(ledger.data)[0] + if (ledgerRow) context.assert(MODULE, '进销存流水关键字段正确', ledger.response, + longId(ledgerRow.id) && id(ledgerRow.customerId) === customerId && nonBlank(ledgerRow.businessType) + && Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages)) + && Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt), + `id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS) + await overseasCsv(context, '下载SKU库存余额CSV', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId, page: 1, size: 200 })) + await overseasCsv(context, '下载进销存流水CSV', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId, page: 1, size: 200 })) + + coverageGap(context, '换标创建与状态生命周期', + '当前 Admin 换标新增/审核仍使用不可提交的契约提示弹框,前端未真实接入写 API;验收模块不越过页面范围直接创建换标单', 'frontend-scope') + + const warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) + if (!warehouse) return + const warehouseId = id(warehouse.id) + context.createdResources.phase4OverseasWarehouse.customerId = customerId + context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId + const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 })) + assertPage(context, '公共托盘列表', pallets) + const pallet = records(pallets.data)[0] + if (pallet) { + context.assert(MODULE, '公共托盘关键字段正确', pallets.response, + longId(pallet.id) && id(pallet.warehouseId) === warehouseId && id(pallet.customerId) === '0' + && nonBlank(pallet.palletCode) && ['IDLE', 'OCCUPIED', 'SCRAPPED'].includes(pallet.status), + `id=${id(pallet.id) || 'missing'}, customerId=${id(pallet.customerId) || 'missing'}, status=${pallet.status || 'missing'}; ${trace(pallets.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`) + context.assert(MODULE, '公共托盘详情对应列表记录', detail.response, + id(detail.data?.id) === id(pallet.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0', + `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } + + await verifyPalletLifecycle(context, warehouseId) + context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId } +} + +async function verifyPalletLifecycle(context, warehouseId) { + const marker = String(context.marker || '').replace(/[^A-Za-z0-9_-]/g, '').slice(-48) || 'PHASE4' + const palletCode = `ACC-${marker}`.slice(0, 128) + let palletId = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮公共托盘', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker, + }) + palletId = id(created.data) + context.createdResources.phase4OverseasWarehouse.palletId = palletId || null + context.assert(MODULE, '公共托盘创建返回长ID', created.response, longId(palletId), + `palletId=${palletId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS) + if (!palletId) return + + const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}` + const detail = await overseasGet(context, '查询本轮公共托盘详情', detailPath) + context.assert(MODULE, '本轮公共托盘创建事实正确', detail.response, + id(detail.data?.id) === palletId && id(detail.data?.warehouseId) === warehouseId + && id(detail.data?.customerId) === '0' && detail.data?.palletCode === palletCode && detail.data?.status === 'IDLE', + `palletId=${id(detail.data?.id) || 'missing'}, code=${detail.data?.palletCode || 'missing'}, customerId=${id(detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + + const updated = await overseasMutation(context, 'PUT', '编辑本轮公共托盘', detailPath, { + palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE', + locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, + }) + context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null, + `palletId=${palletId}; ${trace(updated.response)}`, OPTIONS) + const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath) + context.assert(MODULE, '公共托盘编辑字段已生效', after.response, + id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5 + && Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`, + `capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS) + coverageGap(context, '公共托盘旧版本并发冲突', + '当前 Pallet SaveRequest/PUT 未提供 version 或 expectedVersion,无法构造稳定旧版本冲突且禁止伪造冲突请求', 'missing-version-contract') + } finally { + if (palletId) { + const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, { + palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED', + remark: `${context.marker}-acceptance-retired`, + }) + context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null + context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null, + `palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS) + const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) + context.assert(MODULE, '本轮公共托盘终态为已报废', retiredDetail.response, + id(retiredDetail.data?.id) === palletId && retiredDetail.data?.status === 'SCRAPPED', + `palletId=${id(retiredDetail.data?.id) || 'missing'}, status=${retiredDetail.data?.status || 'missing'}; ${trace(retiredDetail.response)}`, OPTIONS) + } + } +} + +async function findWarehouse(context, tenants, preferredCustomerId) { + const candidates = [...new Set([preferredCustomerId, ...tenants.map((item) => id(item?.id)).filter(longId)])] + let lastResponse = null + for (const customerId of candidates) { + const result = await overseasGet(context, `查询客户${customerId}可用海外仓`, query('/api/platform/v1/warehouse-admin', { customerId, page: 1, size: 100, status: 'ACTIVE' })) + assertPage(context, `客户${customerId}可用海外仓`, result) + lastResponse = result.response + const warehouse = records(result.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') + if (warehouse) { + context.assert(MODULE, '海外仓关键字段正确', result.response, + nonBlank(warehouse.warehouseCode) && nonBlank(warehouse.warehouseName), + `warehouseId=${id(warehouse.id)}, code=${warehouse.warehouseCode || 'missing'}; ${trace(result.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(warehouse.id))}`, { customerId })) + context.assert(MODULE, '海外仓详情对应列表记录', detail.response, + id(detail.data?.warehouse?.id || detail.data?.id) === id(warehouse.id), + `listId=${id(warehouse.id)}, detailId=${id(detail.data?.warehouse?.id || detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + return warehouse + } + } + coverageGap(context, '公共托盘真实仓库前置', `ACTIVE 客户列表未找到可用海外仓;${trace(lastResponse)}`, 'missing-readonly-warehouse') + return null +} diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs new file mode 100644 index 000000000..e78eab28c --- /dev/null +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { parseArguments } from './admin-test-env-acceptance.mjs' + +const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') + +test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => { + for (const module of [ + 'phase4-overseas-warehouse', + 'phase4-overseas-business', + 'phase4-overseas-master-data', + 'phase4-carrier-nodes', + ]) { + assert.match(source, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's')) + } +}) + +test('entry accepts a separate overseas Gateway without exposing token state', () => { + const options = parseArguments([ + '--gateway', 'http://localhost:9000/', + '--overseas-gateway', 'http://localhost:9300/', + '--modules', 'phase4-overseas-warehouse', + ]) + assert.equal(options.gateway, 'http://localhost:9000') + assert.equal(options.overseasGateway, 'http://localhost:9300') + assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) + assert.match(source, /async requestOverseas\(/) + assert.match(source, /async requestOverseasBinary\(/) + assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) +})