This commit is contained in:
2026-08-21 10:38:28 +08:00
parent 361e6be3be
commit d5e9ba6d4c
21 changed files with 1705 additions and 15 deletions
@@ -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<ApiResponse<Void>> 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<ApiResponse<Void>> 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;
};
}
}
@@ -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) {
}
}
@@ -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;
}
}
}
@@ -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();
}
@@ -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() {
@@ -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());
}
}
@@ -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);
@@ -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");
}
}
@@ -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");
}
}
@@ -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);
+65 -4
View File
@@ -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 <url> Gateway 地址,默认 ${DEFAULT_GATEWAY}
--overseas-gateway <url> 海外 Gateway 地址,默认 ${DEFAULT_OVERSEAS_GATEWAY}
--username <name> 管理员账号,默认 ${DEFAULT_USERNAME}
--password <value> 管理员密码,默认使用上方测试密码
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
--output <path> 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,
+10
View File
@@ -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 冻结链
@@ -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`)
})
@@ -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)
}
@@ -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)
})
@@ -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)
}
@@ -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)
})
@@ -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))
}
}
@@ -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, /旧版本并发冲突/)
})
@@ -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
}
@@ -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\]/)
})