优化
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+15
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
+39
@@ -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");
|
||||
}
|
||||
}
|
||||
+13
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\]/)
|
||||
})
|
||||
Reference in New Issue
Block a user