This commit is contained in:
2026-08-24 19:58:35 +08:00
parent 9e0f41e78b
commit c93fdc44d5
23 changed files with 965 additions and 313 deletions
+102 -25
View File
@@ -1,18 +1,10 @@
#!/usr/bin/env node
// Test environment administrator / 测试环境管理员
// Username: platform_admin
// Password: ChangeMe123!
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
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-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', '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-pda', 'phase4-cross-region-events', '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) {
@@ -65,6 +57,13 @@ export function normalizeModuleOrder(modules) {
&& !normalized.includes('merchant-qualification-fixture')) {
normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture')
}
if (normalized.includes('phase4-overseas-pda')) {
const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence',
'phase3-evidence-gate', 'phase3-loading-finalize']
const targetIndex = normalized.indexOf('phase4-overseas-pda')
const missing = prerequisites.filter((name) => !normalized.includes(name))
if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing)
}
if (normalized.includes('phase4-cross-region-events')) {
const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
@@ -130,7 +129,7 @@ export function normalizeModuleOrder(modules) {
}
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
'contactName', 'contactPhone', 'contactEmail', 'contactWechat',
@@ -148,21 +147,20 @@ const BOX_DEFAULTS = [
function usage() {
console.log(`后台管理测试环境 HTTP API 验收
测试管理员: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}
用法:
node scripts/admin-test-env-acceptance.mjs [options]
选项:
--gateway <url> Gateway 地址,默认 ${DEFAULT_GATEWAY}
--overseas-gateway <url> 海外 Gateway 地址,默认 ${DEFAULT_OVERSEAS_GATEWAY}
--username <name> 管理员账号,默认 ${DEFAULT_USERNAME}
--password <value> 管理员密码,默认使用上方测试密码
--gateway <url> 必填,国内 Gateway 地址
--overseas-gateway <url> 四期海外模块必填,海外 Gateway 地址
--username <name> 必填,管理员账号
--password <value> 必填,管理员密码
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
--output <path> JSON 报告路径,默认 reports/
--help 显示帮助
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
ADMIN_TEST_WEIGHT_IMAGE_PATH`)
}
export function parseArguments(argv) {
@@ -182,15 +180,26 @@ 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,
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''),
overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''),
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '',
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '',
modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)),
output: values.get('output') || '',
}
}
export function validateExecutionOptions(options) {
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`)
}
if (options.modules.some((module) => module.startsWith('phase4-overseas-'))
&& (!options.overseasGateway || options.overseasGateway.trim() === '')) {
throw new Error('四期海外模块缺少必填执行参数: overseas-gateway')
}
return options
}
function own(value, field) { return value !== null && typeof value === 'object' && Object.hasOwn(value, field) }
function hasFields(value, fields) { return fields.every((field) => own(value, field)) }
function numberEquals(actual, expected) { return Math.abs(Number(actual) - Number(expected)) < 0.0005 }
@@ -383,7 +392,13 @@ export class AcceptanceRun {
}
addCoverageGap(module, scenario, name, reason, category = 'not-implemented', metadata = {}) {
this.coverageGaps.push({ module, scenario, name, reason, category, ...metadata })
const nonBlocking = new Set(['device', 'external-third-party', 'visual'])
const blocking = metadata.blocking ?? !nonBlocking.has(category)
this.coverageGaps.push({ module, scenario, name, reason, category, blocking, ...metadata })
if (blocking) {
this.addStep(module, `覆盖阻断:${name}`, null, false, reason,
{ scenario, deploymentBlocker: true })
}
}
apiSuccess(module, name, response, evidence = '', options = {}) {
@@ -757,9 +772,9 @@ export class AcceptanceRun {
}
}
async preparePhase3LoadingFixture() {
async preparePhase3LoadingFixture(suffix = '') {
const originalMarker = this.marker
const fixtureMarker = `${originalMarker}-LOAD`
const fixtureMarker = `${originalMarker}-LOAD${suffix}`
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount']
@@ -798,7 +813,7 @@ export class AcceptanceRun {
internalRemark: fixtureOrder.internalRemark,
cargoRemark: fixtureOrder.cargoRemark,
} : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true }
const incrementalMarker = `${originalMarker}-LOAD-INC`
const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC`
this.marker = incrementalMarker
this.context.phase3LoadingRequireSharedOverflow = false
this.context.phase3LoadingIncrementalPackageCount = 2
@@ -834,6 +849,59 @@ export class AcceptanceRun {
}
}
async runPhase4ProducerVariant(label, destinationType) {
const matrixMarker = this.marker
const variantMarker = `${matrixMarker}-P4-${label}`
const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker']
const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer']
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
present: Object.hasOwn(this.context, key), value: this.context[key],
}]))
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
}]))
let restoreFixture = null
this.marker = variantMarker
this.context.orderDestinationType = destinationType
this.context.phase4ProducerMatrixMarker = matrixMarker
try {
restoreFixture = await this.preparePhase3LoadingFixture()
const loading = await import(new URL('./admin-test-env-modules/phase3-loading.mjs', import.meta.url))
await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this))
const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url))
await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this))
} finally {
if (restoreFixture) restoreFixture()
this.marker = matrixMarker
for (const [key, snapshot] of contextSnapshot) {
if (snapshot.present) this.context[key] = snapshot.value
else delete this.context[key]
}
for (const [key, snapshot] of createdSnapshot) {
if (snapshot.present) this.createdResources[key] = snapshot.value
else delete this.createdResources[key]
}
}
}
async ensurePhase4PdaProducerMatrix() {
const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {}
if (Number(readiness().fbaContainerCount || 0) < 1) {
await this.runPhase4ProducerVariant('FBA-PRIMARY', 'FBA')
}
if (Number(readiness().overseasContainerCount || 0) < 1) {
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS')
}
if (Number(readiness().distinctContainerCount || 0) < 3) {
await this.runPhase4ProducerVariant('SECONDARY', 'FBA')
}
const matrix = this.createdResources.phase4InboundProducerFixtures
this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null,
matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true,
`marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`,
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
}
async disableAvailableLocationsForA0(warehouseId) {
const module = 'phase3-loading-fixture'
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
@@ -912,6 +980,15 @@ export class AcceptanceRun {
continue
}
let restoreFixture = null
if (module === 'phase4-overseas-pda') {
try {
await this.ensurePhase4PdaProducerMatrix()
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.addStep('phase4-overseas-pda-fixture', '创建FBA/OVERSEAS/第二柜三期生产矩阵', null,
false, reason, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
}
}
const ocrIndex = this.options.modules.indexOf('inbound-weight-ocr')
if (module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex) {
try {
@@ -1001,7 +1078,7 @@ export class AcceptanceRun {
}
async function main() {
const options = parseArguments(process.argv.slice(2))
const options = validateExecutionOptions(parseArguments(process.argv.slice(2)))
const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14)
const outputPath = resolve(options.output || `reports/admin-test-env-acceptance-${timestamp}.json`)
const report = await new AcceptanceRun(options).run()
+2 -2
View File
@@ -55,7 +55,7 @@ export async function run(context) {
| 消息中心管理 | `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` |
| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` |
| 四期海外 PDA 柜号收货 HTTP 生命周期 | `phase4-overseas-pda.mjs`(客户海外 Token 按柜号执行 candidates/detail/prepare、三角色照片签收、FBA SKU 箱数或 OVERSEAS 逐箱实收、完成与幂等/冲突边界;真实柜、箱唛或托盘前置缺失时 fail-closed) | `phase4-overseas-pda-contract.test.mjs` |
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-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` |
@@ -64,7 +64,7 @@ export async function run(context) {
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块使用本轮客户账号的海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID,也不调用出库、派送或内部接口。收货占用的托盘作为库存事实保留,无法自动取得真实柜、箱唛或托盘时必须形成 coverage gap 和部署阻断,不能按空场景通过;船节点禁止手工创建任务。
```powershell
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
@@ -491,6 +491,9 @@ export async function run(context) {
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
destinationType: context.createdResources?.orders?.destinationType || null,
destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null,
destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null,
}
finishScenario(context, start)
}
+32 -18
View File
@@ -20,6 +20,10 @@ function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite
function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' }
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
export function requestedOrderDestinationType(context) {
return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS'
? 'OVERSEAS' : 'FBA'
}
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
const summary = orderDetail?.summary
const task = success(taskResponse) ? taskResponse.body?.data : null
@@ -33,12 +37,12 @@ function tomorrow(days = 30) {
return date.toISOString().slice(0, 10)
}
function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) {
function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType = 'FBA') {
return {
originWarehouseId: receiving.id,
channelCode: channel.channelCode,
destinationCode: destination.warehouseCode,
destinationType: 'FBA',
destinationType,
destinationWarehouseId: destination.id,
destinationName: destination.warehouseName,
pricingTaxMode,
@@ -56,7 +60,7 @@ function estimateMatches(response, candidate) {
}
export async function findPricedOrderRoute({ receiving, destinations, channels, estimate,
concurrency = PRICING_PROBE_CONCURRENCY }) {
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
const candidates = []
for (const channel of channels) {
if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue
@@ -65,7 +69,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels,
for (const destination of destinations) {
if (!destination?.id || !destination?.warehouseCode) continue
candidates.push({ channel, pricingTaxMode, destination,
request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) })
request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType) })
}
}
}
@@ -98,7 +102,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels,
}
export async function findPricedOrderRouteAcrossOrigins({ receivings, destinations, loadChannels, estimate,
concurrency = PRICING_PROBE_CONCURRENCY }) {
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
const originFailures = []
let attempts = 0
let response = null
@@ -115,7 +119,8 @@ export async function findPricedOrderRouteAcrossOrigins({ receivings, destinatio
traceId: channelResponse?.traceId ?? 'missing', count: channels.length })
continue
}
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, estimate, concurrency })
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels,
estimate, concurrency, destinationType })
attempts += result.attempts
response = result.response || response
if (result.priced) return { receiving, ...result, originFailures }
@@ -196,19 +201,21 @@ async function assertProductImageDownload(context, merchantToken, fileId) {
}
async function loadMasterData(context, merchantToken) {
const destinationType = requestedOrderDestinationType(context)
const domesticResponse = await request(context, 'GET',
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken)
const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse)
context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0,
`count=${records(domesticPage).length}`)
const fbaResponse = await request(context, 'GET',
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', undefined, merchantToken)
const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse)
context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0,
`count=${records(fbaPage).length}`)
const destinationResponse = await request(context, 'GET',
`/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
undefined, merchantToken)
const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse)
context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse,
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
const destinations = records(fbaPage)
const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
const pricingResult = await findPricedOrderRouteAcrossOrigins({
@@ -217,6 +224,7 @@ async function loadMasterData(context, merchantToken) {
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
undefined, merchantToken),
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
destinationType,
})
const priced = pricingResult.priced
const receiving = pricingResult.receiving
@@ -225,7 +233,7 @@ async function loadMasterData(context, merchantToken) {
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}`
: originPricingFailureEvidence(pricingResult),
CONTRACT_OPTIONS)
if (!priced) throw new Error('当前 ACTIVE 国内收货仓、渠道、报价税制和FBA目的仓没有可计价组合')
if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`)
const partnerResponse = await request(context, 'GET',
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
@@ -234,7 +242,7 @@ async function loadMasterData(context, merchantToken) {
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
: null
return { receiving, destination: priced.destination, channel: priced.channel,
pricingTaxMode: priced.pricingTaxMode, partner }
pricingTaxMode: priced.pricingTaxMode, destinationType, partner }
}
function buildPayload(context, profile, master, productImageFileId) {
@@ -254,9 +262,10 @@ function buildPayload(context, profile, master, productImageFileId) {
channelCode: master.channel.channelCode, channelName: master.channel.channelName,
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL',
customsInspection: false, destinationType: 'FBA',
amazonWarehouseCode: master.destination.warehouseCode,
shipmentId, referenceId,
customsInspection: false, destinationType: master.destinationType,
amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null,
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
referenceId: master.destinationType === 'FBA' ? referenceId : null,
sender: {
companyName, contactName: '订单验收联系人', phone: '13800138000',
email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省',
@@ -279,7 +288,9 @@ function buildPayload(context, profile, master, productImageFileId) {
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
shipmentId, referenceId, upcNo: `${Date.now()}${index}`.slice(-12),
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
referenceId: master.destinationType === 'FBA' ? referenceId : null,
upcNo: `${Date.now()}${index}`.slice(-12),
productImageFileId, dangerousGoods: false,
cargoRemark: `${context.marker} boxType=${box.boxType}`,
}
@@ -387,6 +398,9 @@ export async function run(context) {
marker: context.marker, tenantId, orderId, productImageFileId,
boxTypes: BOXES.map((box) => box.boxType), inboundId: null,
receivingWarehouseId: id(master.receiving.id),
destinationType: master.destinationType,
destinationWarehouseId: id(master.destination.id),
destinationWarehouseCode: master.destination.warehouseCode,
}
const replayResponse = await request(context, 'POST', createPath, payload, merchantToken,
@@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches'
export async function run(context) {
const fixture = context.createdResources?.phase3OutboundDispatch
if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) {
context.addStep(MODULE, '取得同 marker 的国内出仓门禁前置', null, false,
`inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, '订舱/拖车/报关/PDA/五类证据统一硬门禁',
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
return
@@ -14,6 +14,9 @@ export async function run(context) {
const warehouseId = id(fixture?.warehouseId)
if (!tenantId || !inboundId || !taskId || !loadingPlanId || !containerBookingId || !warehouseId
|| fixture.marker !== context.marker) {
context.addStep(MODULE, '取得同 marker 的五证据门禁前置', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, '五类证据多图追加、作废审计和有效证据门禁',
'本轮未取得带 taskId/inboundId/loadingPlanId/containerBookingId 的 phase3-loading marker 隔离证据 fixture,未使用历史资源', 'fixture')
return
@@ -44,6 +47,8 @@ export async function run(context) {
evidenceReadable,
`processStatus=${inbound?.summary?.processStatus || 'missing'}, taskWorkflow=${task?.workflowStatus || 'missing'}`, OPTIONS)
if (!evidenceReadable) {
context.addStep(MODULE, '装柜证据读取前置必须由服务端进入 LOADING', inboundResponse, false,
`processStatus=${inbound?.summary?.processStatus || 'missing'};不能以 AWAITING_REVIEW 或 coverageGap 代替装柜阶段事实`, OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, '服务端装柜阶段前不请求证据',
`taskId=${taskId}, inboundId=${inboundId}, loadingPlanId=${loadingPlanId}, containerBookingId=${containerBookingId}, processStatus=${inbound?.summary?.processStatus || 'missing'};AWAITING_REVIEW 不作为证据开放条件`, 'deployment-blocker')
return
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const read = (name) => readFileSync(resolve(here, name), 'utf8')
test('phase3 downstream modules turn missing current-marker prerequisites into deployment blockers', () => {
const expectations = new Map([
['phase3-loading-model-bridge.mjs', '取得同 marker 的预装与柜次桥接前置'],
['phase3-pda-convergence.mjs', '取得同 marker 的 PDA 收敛前置'],
['phase3-evidence-gate.mjs', '取得同 marker 的五证据门禁前置'],
['phase3-outbound-dispatch.mjs', '取得同 marker 的 LOADED 国内出仓前置'],
['phase3-outbound-aggregate.mjs', '取得同 marker 的出仓主聚合前置'],
['phase3-dispatch-gate.mjs', '取得同 marker 的国内出仓门禁前置'],
['phase3-waiting-for-loading.mjs', '取得同 marker 的 WAITING_FOR_LOADING 前置'],
])
for (const [file, step] of expectations) {
const source = read(file)
assert.match(source, /deploymentBlocker: true/, file)
assert.match(source, new RegExp(`addStep\\(MODULE, '${step}'[\\s\\S]{0,500}false`), file)
assert.match(source, /fixtureMarker=.*runMarker=/, file)
}
})
test('phase3 inventory mutation and downstream gates cannot pass as coverage gaps', () => {
const loading = read('phase3-loading.mjs')
const convergence = read('phase3-pda-convergence.mjs')
const dispatch = read('phase3-outbound-dispatch.mjs')
const aggregate = read('phase3-outbound-aggregate.mjs')
assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/)
assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/)
assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/)
assert.match(aggregate, /统一时间线必须包含报关与三期船期历史[\s\S]{0,300}false/)
})
test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => {
const source = read('phase3-outbound-dispatch.mjs')
for (const step of [
'confirm-dispatch 幂等重放',
'相同 Idempotency-Key 不同请求拒绝',
'reverse-dispatch 幂等重放',
'reverse-dispatch 同键异参返回 A0409',
'撤销后旧版本再次确认返回 A0409',
'负向与幂等验证后回查出仓聚合',
'出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史',
]) assert.match(source, new RegExp(step))
})
@@ -79,7 +79,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi
})
test('downstream finalizer uses the frozen truck-coupled complete contract', () => {
assert.match(convergence, /summary\?\.status === 'LOADING'/)
assert.match(convergence, /loadingPlan\?\.status === 'LOADING'/)
assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/)
assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/)
assert.match(customs, /ensureReleasedOceanBooking/)
@@ -27,6 +27,35 @@ export function buildFinalizeResource(fixture, finalData, completeResponse) {
}
}
export function collectPhase4InboundProducerFixture(previous, producer) {
const existing = previous?.marker === producer?.marker && Array.isArray(previous?.containers)
? previous.containers : []
const containers = [...existing.filter((item) => item.eventKey !== producer.eventKey), producer]
const byDestinationType = Object.fromEntries(['FBA', 'OVERSEAS'].map((destinationType) => [
destinationType,
containers.filter((item) => item.destinationType === destinationType),
]))
const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size
return {
marker: producer.marker,
containers,
byDestinationType,
readiness: {
fbaContainerCount: byDestinationType.FBA.length,
overseasContainerCount: byDestinationType.OVERSEAS.length,
distinctContainerCount,
readyForPhase4Pda: byDestinationType.FBA.length >= 1
&& byDestinationType.OVERSEAS.length >= 1
&& distinctContainerCount >= 3,
},
requiredEntryOrchestration: [
'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
],
}
}
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
@@ -323,4 +352,27 @@ export async function run(context) {
settlementMode,
settledOrderId: id(loadedSlice?.childOrderId) || orderId,
}
const producer = {
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED',
eventVersion: 2,
eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`,
resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`,
tenantId,
aggregateType: 'LOADING_PLAN',
aggregateId: loadingPlanId,
loadingPlanId,
containerBookingId,
sourceOrderId: orderId,
sourceInboundId: inboundId,
destinationType: fixture.destinationType || null,
destinationWarehouseId: id(fixture.destinationWarehouseId),
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
containerNo: fixture.containerNo || null,
sourceStatus: 'LOADED',
verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP',
}
context.createdResources.phase3LoadingCompletedProducer = producer
context.createdResources.phase4InboundProducerFixtures = collectPhase4InboundProducerFixture(
context.createdResources.phase4InboundProducerFixtures, producer)
}
@@ -32,6 +32,9 @@ export async function run(context) {
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
const loadingPlanId = id(fixture?.loadingPlanId)
if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) {
context.addStep(MODULE, '取得同 marker 的预装与柜次桥接前置', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联',
'本轮未取得同一 marker 的预装/柜次计划关联 fixture,未使用历史资源', 'fixture',
{ activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } })
@@ -101,9 +101,10 @@ export async function run(context) {
const source = fixture(context)
const tenantId = id(source?.tenantId)
const inboundId = id(source?.inboundId)
if (!tenantId || !inboundId) {
if (!tenantId || !inboundId || source?.marker !== context.marker) {
context.addStep(MODULE, '取得本轮 WAIT_SPLIT 隔离入库单', null, false,
'未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单', OPTIONS)
`未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单;fixtureMarker=${source?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
return
}
@@ -697,6 +698,8 @@ export async function run(context) {
effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken }
}
} else {
context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false,
`本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
`inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`,
'fixture')
@@ -1159,6 +1162,9 @@ export async function run(context) {
context.createdResources.phase3Loading = {
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
destinationType: initial?.summary?.destinationType || source?.destinationType || null,
destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId),
destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null,
evidenceIds: evidenceItems.map((item) => id(item.id)),
voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`,
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
@@ -16,6 +16,9 @@ function ordered(items) {
export async function run(context) {
const fixture = context.createdResources?.phase3OutboundDispatch
if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) {
context.addStep(MODULE, '取得同 marker 的出仓主聚合前置', null, false,
`inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, '出仓单/柜次主聚合与整体时间线',
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
return
@@ -49,4 +52,6 @@ export async function run(context) {
context.addCoverageGap(MODULE, OPTIONS.scenario, '报关与船期历史并入统一时间线',
'当前 OutboundDispatchReadMapper 时间线仅聚合入库、装柜计划、PDA、证据、订舱、拖车和出仓历史,未公开报关与船期历史投影',
'contract-gap')
context.addStep(MODULE, '统一时间线必须包含报关与三期船期历史', response, false,
'当前服务端聚合未公开 CUSTOMS 与 SAILING_SCHEDULE 时间线,不能以 coverageGap 计为自动验收通过', OPTIONS)
}
@@ -36,6 +36,9 @@ export async function run(context) {
const containerBookingId = id(fixture?.containerBookingId)
if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus)
|| fixture?.marker !== context.marker) {
context.addStep(MODULE, '取得同 marker 的 LOADED 国内出仓前置', null, false,
`inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, finalStatus=${fixture?.finalStatus || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环',
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
return
@@ -85,6 +88,9 @@ export async function run(context) {
{ 'Idempotency-Key': confirmKey })
context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked),
`HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS)
context.addStep(MODULE, '国内出仓八项门禁必须在同轮全部通过', gateResponse, false,
`failed=${gateItems.filter((item) => !item.passed).map((item) => `${item.code}:${item.actual || 'missing'}`).join(',') || 'gate.passed=false'}`,
OPTIONS)
for (const item of gateItems.filter((value) => !value.passed)) {
context.addCoverageGap(MODULE, OPTIONS.scenario, item.name || item.code,
`${item.code}: actual=${item.actual || 'missing'}, requirement=${item.requirement || 'missing'}`,
@@ -124,6 +130,37 @@ export async function run(context) {
reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED'
&& reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'),
`dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS)
const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, {
inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
}, { 'Idempotency-Key': reverseKey })
const reverseReplay = context.apiSuccess(MODULE, 'reverse-dispatch 幂等重放', reverseReplayResponse, '', OPTIONS)
context.assert(MODULE, '撤销重放保持 READY_FOR_DISPATCH 与同一版本', reverseReplayResponse,
reverseReplay?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH'
&& Number(reverseReplay?.aggregate?.version) === Number(reversed?.aggregate?.version),
`dispatch=${reverseReplay?.aggregate?.dispatchStatus || 'missing'}, version=${reverseReplay?.aggregate?.version ?? 'missing'}`, OPTIONS)
const reverseHashConflict = await request(context, 'POST', `${BASE}/reverse-dispatch`, {
inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: 'forged',
}, { 'Idempotency-Key': reverseKey })
context.addStep(MODULE, 'reverse-dispatch 同键异参返回 A0409', reverseHashConflict,
reverseHashConflict.http === 200 && codeOf(reverseHashConflict) === 'A0409',
`HTTP=${reverseHashConflict.http}, code=${codeOf(reverseHashConflict)}`, OPTIONS)
const staleConfirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
{ 'Idempotency-Key': `${context.marker}-outbound-confirm-stale`.slice(0, 128) })
context.addStep(MODULE, '撤销后旧版本再次确认返回 A0409', staleConfirmResponse,
staleConfirmResponse.http === 200 && codeOf(staleConfirmResponse) === 'A0409',
`HTTP=${staleConfirmResponse.http}, code=${codeOf(staleConfirmResponse)}`, OPTIONS)
const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`)
const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS)
context.assert(MODULE, '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', finalDetailResponse,
finalDetail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH'
&& finalDetail?.aggregate?.inboundStatus === 'LOADED'
&& Number(finalDetail?.aggregate?.version) === Number(reversed?.aggregate?.version)
&& finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH')
&& finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'),
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
if (reversed?.aggregate) {
context.createdResources.phase3OutboundDispatch = {
marker: context.marker, inboundId, containerBookingId,
@@ -27,6 +27,9 @@ export async function run(context) {
const warehouseId = id(fixture?.warehouseId); const loadingPlanId = id(fixture?.loadingPlanId)
if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || !loadingPlanId
|| fixture?.marker !== context.marker) {
context.addStep(MODULE, '取得同 marker 的 PDA 收敛前置', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛',
'本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture')
return
@@ -76,6 +79,10 @@ export async function run(context) {
const operationsResponse = await request(context, 'GET',
`${taskPath}/operations`)
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
const batchOperations = Array.isArray(operations)
? operations.filter((item) => item.actionType === 'BATCH'
&& Boolean(item.operatorId) && Boolean(item.createdAt)) : []
const expectedBatchCount = fixture?.dynamicIncrementVerified === true ? 2 : 1
context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse,
Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM'
&& Boolean(item.operatorId) && Boolean(item.createdAt))
@@ -84,9 +91,9 @@ export async function run(context) {
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
Array.isArray(operations) && operations.filter((item) => item.actionType === 'BATCH'
&& Boolean(item.operatorId) && Boolean(item.createdAt)).length >= 1,
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
batchOperations.length === expectedBatchCount
&& new Set(batchOperations.map((item) => item.operationId)).size === expectedBatchCount,
`expectedBatchCount=${expectedBatchCount}, actualBatchCount=${batchOperations.length}, operationIds=${batchOperations.map((item) => item.operationId || 'missing').join(',')}`, OPTIONS)
const forged = await request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' },
@@ -95,21 +102,31 @@ export async function run(context) {
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
if (fixture?.atomicBatchVerified !== true) {
context.addStep(MODULE, 'PDA 原子批次成功且同键重放零新增', null, false,
'phase3-loading 未发布 atomicBatchVerified=true;不能以 coverageGap 代替真实批次与幂等断言', OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 原子批量成功与批次幂等闭环',
'原子 batch 端点已部署,但本轮合法整批提交或同键重放未通过真实 HTTP 断言;保留真实失败证据',
'deployment-blocker')
}
if (fixture?.inventorySnapshotVerified !== true) {
context.addStep(MODULE, 'PDA 实时库存快照身份与合法 token 提交', null, false,
'phase3-loading 未发布 inventorySnapshotVerified=true;不能以 coverageGap 代替真实库存快照断言', OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存快照身份、稳定性与合法 token 提交',
'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证',
'deployment-blocker')
}
if (fixture?.inventoryMutationVerified !== true
&& !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) {
context.addStep(MODULE, 'PDA 真实库存变化后旧 token A0409 与刷新重试', null, false,
'本轮未取得 inventoryMutationVerified=true;无法安全制造库存变化时必须作为部署阻断失败', OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化',
'fixture')
}
if (fixture?.dynamicIncrementVerified !== true) {
context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false,
'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS)
}
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环',
'原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收',
'device')
@@ -25,6 +25,9 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat
assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/)
assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/)
assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/)
assert.match(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified === true \? 2 : 1/)
assert.match(convergence, /new Set\(batchOperations\.map\(\(item\) => item\.operationId\)\)\.size === expectedBatchCount/)
assert.match(convergence, /LOADING 增量重开、刷新续拣与已拣不可删减/)
assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/)
})
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { requestedOrderDestinationType } from './orders.mjs'
import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const read = (name) => readFileSync(resolve(here, name), 'utf8')
test('order fixture defaults to FBA and accepts only the explicit OVERSEAS variant', () => {
assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA')
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS')
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'FBA')
const orders = read('orders.mjs')
assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/)
assert.match(orders, /destinationType: master\.destinationType/)
assert.match(orders, /destinationType,\s*\n\s*\}\)/)
assert.doesNotMatch(orders, /const fbaResponse =/)
})
test('producer collector requires one FBA, one OVERSEAS and a third distinct container', () => {
const common = { marker: 'RUN-1', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', sourceStatus: 'LOADED' }
const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', containerNo: 'FBA-001' }
const overseasPrimary = { ...common, eventKey: 'OS-EVENT-1', destinationType: 'OVERSEAS', containerNo: 'OS-001' }
const secondary = { ...common, eventKey: 'FBA-EVENT-2', destinationType: 'FBA', containerNo: 'FBA-002' }
const one = collectPhase4InboundProducerFixture(null, fba)
const two = collectPhase4InboundProducerFixture(one, overseasPrimary)
const ready = collectPhase4InboundProducerFixture(two, secondary)
assert.equal(one.readiness.readyForPhase4Pda, false)
assert.equal(two.readiness.readyForPhase4Pda, false)
assert.equal(ready.readiness.readyForPhase4Pda, true)
assert.equal(ready.readiness.fbaContainerCount, 2)
assert.equal(ready.readiness.overseasContainerCount, 1)
assert.equal(ready.readiness.distinctContainerCount, 3)
assert.equal(ready.byDestinationType.FBA[1].eventKey, 'FBA-EVENT-2')
})
test('same event key is idempotently replaced and another marker starts a new matrix', () => {
const first = collectPhase4InboundProducerFixture(null,
{ marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' })
const replay = collectPhase4InboundProducerFixture(first,
{ marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' })
assert.equal(replay.containers.length, 1)
const nextRun = collectPhase4InboundProducerFixture(replay,
{ marker: 'RUN-2', eventKey: 'EVENT-2', destinationType: 'OVERSEAS', containerNo: 'OS-002' })
assert.equal(nextRun.containers.length, 1)
assert.equal(nextRun.marker, 'RUN-2')
assert.deepEqual(nextRun.requiredEntryOrchestration, [
'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
])
})
test('measurement and loading pass destination identity into the LOADED producer key', () => {
const measurement = read('inbound-measurement.mjs')
const loading = read('phase3-loading.mjs')
const finalize = read('phase3-loading-finalize.mjs')
assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/)
assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/)
assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/)
assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/)
assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/)
})
@@ -627,3 +627,14 @@ test('loaded boundary acceptance proves evidence and transport immutability with
assert.match(finalize, /afterHistorySignature === historySignature/)
assert.match(finalize, /afterPlanSignature === immutablePlanSignature/)
})
test('finalize publishes a current-marker producer correlation for cross-region verification', () => {
const finalize = source('./phase3-loading-finalize.mjs')
assert.match(finalize, /const producer = \{/)
assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/)
assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/)
assert.match(finalize, /eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED'/)
assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:2`/)
assert.match(finalize, /sourceStatus: 'LOADED'/)
assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/)
})
@@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches'
export async function run(context) {
const fixture = context.createdResources?.phase3OutboundDispatch
if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) {
context.addStep(MODULE, '取得同 marker 的 WAITING_FOR_LOADING 前置', null, false,
`inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'WAITING_FOR_LOADING 状态与历史',
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
return
@@ -4,10 +4,16 @@ 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 controllerSource = [
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java',
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java',
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java',
].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n')
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' })
const forbidden = () => ({ http: 403, body: { code: 'A0403', message: 'Access denied', data: null }, requestId: 'req-forbidden' })
function detailQuote(state) {
return {
@@ -22,21 +28,25 @@ function vehicleWire(state) {
return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) }
}
function fakeContext() {
function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
const calls = []
const coverageGaps = []
const steps = []
const marker = 'ADMIN-API-20260821123456789'
const state = { warehouse: null, quote: null, exchange: null, vehicle: null }
const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null }
const context = {
marker, calls, coverageGaps, createdResources: {},
marker, calls, coverageGaps, steps, createdResources: {},
async findAvailablePhone() { return '13800138000' },
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
assert(_module, name, _response, condition, evidence, options = {}) {
const passed = Boolean(condition)
steps.push({ name, passed, deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence })
if (!passed && !softAssertions) assert.equal(passed, true, `${name}: ${evidence}`)
return passed
},
expectConflict(_module, name, response) {
assert.equal(response.http, 200, `${name}: HTTP`)
@@ -64,12 +74,40 @@ function fakeContext() {
state.warehouse.status = 'INACTIVE'
return ok(null)
}
if (method === 'POST' && path === '/api/platform/v1/iam/roles') {
state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' }
return ok({ ...state.role })
}
if (method === 'POST' && path === '/api/platform/v1/iam/users') {
state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' }
return ok({ ...state.user })
}
if (method === 'POST' && path === '/api/auth/login') {
return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 })
}
if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') {
state.user.status = body.status
return ok(null)
}
if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') {
state.role.status = body.status
return ok(null)
}
assert.fail(`unexpected domestic call ${method} ${path}`)
},
async requestOverseas(method, path, body) {
calls.push({ gateway: 'overseas', method, path, body })
async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') {
calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' })
assert.notEqual(method, 'DELETE')
if (token === 'deny-overseas-token') return forbidden()
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) {
state.optionReads += 1
if (!state.warehouse) return ok([])
return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode,
warehouseName: state.warehouse.warehouseName, countryCode: 'US' }])
}
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) {
state.optionReads += 1
if (refreshEmpty) return ok([])
return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode,
warehouseName: state.warehouse.warehouseName, countryCode: 'US' }])
}
@@ -81,6 +119,10 @@ function fakeContext() {
}
return ok(detailQuote(state.quote))
}
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=`)) {
return ok({ records: state.quote ? [{ ...state.quote.summary }] : [], page: 1, size: 20,
total: state.quote ? 1 : 0, pages: state.quote ? 1 : 0 })
}
if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) {
if (method === 'GET') return ok(detailQuote(state.quote))
if (method === 'PUT') {
@@ -100,7 +142,10 @@ function fakeContext() {
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 })
const matches = state.exchange && path.includes(`monthFrom=${state.exchange.month}`)
&& (!path.includes('status=') || path.includes(`status=${state.exchange.status}`))
return ok({ records: matches ? [{ ...state.exchange }] : [], page: 1, size: 20,
total: matches ? 1 : 0, pages: matches ? 1 : 0 })
}
if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) {
state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 }
@@ -139,13 +184,21 @@ function fakeContext() {
state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 }
return ok(vehicleWire(state))
}
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=`)) {
return ok({ records: state.vehicle ? [vehicleWire(state)] : [], page: 1, size: 20,
total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 })
}
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'),
let csv = ''
if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n`
else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n`
else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n`
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`),
contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' }
},
}
@@ -160,13 +213,20 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
assert.match(source, /context\.requestOverseas\('PATCH'/)
assert.match(source, /context\.requestOverseasBinary\('GET'/)
assert.match(source, /context\.expectConflict/)
assert.match(source, /warehouse-options\?refresh=true/)
assert.match(source, /空候选不负缓存30分钟/)
assert.match(source, /30分钟正缓存与刷新快照一致/)
assert.match(source, /无quote:list权限访问海外报价被拒绝/)
assert.match(source, /表头与marker内容/)
assert.match(source, /finally\s*\{/)
assert.match(source, /context\.createdResources\.phase4OverseasMasterData/)
assert.match(source, /context\.addCoverageGap/)
assert.doesNotMatch(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))
assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/)
assert.doesNotMatch(source, /Idempotency-Key/)
})
test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => {
@@ -186,30 +246,39 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全
assert.equal(state.quote.summary.status, 'INACTIVE')
assert.equal(state.exchange.status, 'INACTIVE')
assert.equal(state.vehicle.status, 'INACTIVE')
assert.equal(state.role.status, 'DISABLED')
assert.equal(state.user.status, 'DISABLED')
assert.equal(state.optionReads, 4)
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)
assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9)
assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1)
assert.equal(context.coverageGaps.length, 0)
})
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)
}
test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价完整生命周期', async () => {
const { context } = fakeContext()
await run(context)
const optionCalls = context.calls.filter((call) => call.path.includes('warehouse-options'))
assert.equal(optionCalls.length, 4)
assert.equal(optionCalls[0].path.endsWith('warehouse-options'), true)
assert.equal(optionCalls[1].path.endsWith('warehouse-options'), true)
assert.equal(optionCalls[2].path.endsWith('warehouse-options?refresh=true'), true)
assert.equal(optionCalls[3].path.endsWith('warehouse-options'), true)
assert.equal(context.createdResources.phase4OverseasMasterData.quote.id, '920000000000000001')
assert.equal(context.coverageGaps.length, 0)
})
test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => {
const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true })
await run(context)
assert.equal(state.warehouse.status, 'INACTIVE')
assert.equal(state.quote, null)
assert.equal(state.warehouse.status, 'INACTIVE')
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)
assert.equal(context.coverageGaps.length, 0)
assert.equal(context.steps.some((step) => step.name === '刷新后新建合作仓立即可见'
&& !step.passed && step.deploymentBlocker), true)
assert.equal(context.steps.some((step) => step.name === '海外报价生命周期执行'
&& !step.passed && step.deploymentBlocker), true)
})
@@ -14,13 +14,26 @@ function requireData(context, name, response) {
return value
}
function assertBinaryCsv(context, name, response) {
function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) {
const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : ''
const header = text.split(/\r?\n/, 1)[0]
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)
context.assert(MODULE, `${name}表头与marker内容`, response,
header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))),
`header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER)
}
const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : []
function assertForbidden(context, name, response) {
context.assert(MODULE, name, response,
response?.http === 403 || (response?.http === 200 && ['403', 'A0403'].includes(String(response?.body?.code))),
`HTTP=${response?.http ?? 0}, code=${response?.body?.code ?? 'missing'}`, BLOCKER)
}
async function safeCleanup(context, name, action) {
@@ -97,6 +110,16 @@ async function createAndVerifyPartnerWarehouse(context, resource) {
BLOCKER)
}
async function captureWarehouseOptionsBeforeCreate(context, resource) {
const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response)
context.assert(MODULE, '合作仓候选基线为规范数组', response,
Array.isArray(options) && options.every((item) => asId(item?.warehouseId)
&& item.warehouseCode && item.warehouseName && /^[A-Z]{2}$/.test(item.countryCode)),
`count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER)
resource.partnerWarehouseOptionsBefore = optionIds(options)
}
function quoteCreatePayload(context, warehouseId) {
return {
name: `${context.marker} 海外报价`, warehouseId,
@@ -127,27 +150,25 @@ async function quoteLifecycle(context, resource) {
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 createdId = resource.partnerWarehouse?.id
if (resource.partnerWarehouseOptionsBefore?.length === 0) {
context.assert(MODULE, '空候选不负缓存30分钟', optionsResponse,
optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER)
}
const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id)
const option = createdOption || options[0]
if (!option) throw new Error('海外报价接口未返回可用合作仓候选')
const refreshResponse = await context.requestOverseas('GET',
`${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`)
const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse)
const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null
context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option),
`createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER)
if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`)
const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse)
context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse,
JSON.stringify(cached) === JSON.stringify(refreshed),
`refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER)
const payload = quoteCreatePayload(context, asId(option.warehouseId))
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload)
@@ -164,6 +185,13 @@ async function quoteLifecycle(context, resource) {
&& Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0,
`id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER)
const listResponse = await context.requestOverseas('GET',
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`)
const page = requireData(context, '分页查询marker海外报价', listResponse)
context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse,
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
`id=${id}, total=${page?.total ?? '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)
@@ -191,7 +219,9 @@ async function quoteLifecycle(context, resource) {
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)
assertBinaryCsv(context, '导出海外报价CSV', csv,
'报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间',
[id, context.marker])
return true
}
@@ -252,9 +282,17 @@ async function exchangeLifecycle(context, resource) {
context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse,
rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER)
const listResponse = await context.requestOverseas('GET',
`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`)
const page = requireData(context, '分页查询marker月汇率', listResponse)
context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse,
Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)),
`month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER)
const csv = await context.requestOverseasBinary('GET',
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
assertBinaryCsv(context, '导出marker月汇率CSV', csv)
assertBinaryCsv(context, '导出marker月汇率CSV', csv,
'月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE'])
}
function vehiclePayload(context, edited = false) {
@@ -316,8 +354,48 @@ async function vehicleLifecycle(context, resource) {
context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse,
vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER)
const listResponse = await context.requestOverseas('GET',
`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`)
const page = requireData(context, '分页查询marker车辆', listResponse)
context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse,
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
assertBinaryCsv(context, '导出marker车辆CSV', csv)
assertBinaryCsv(context, '导出marker车辆CSV', csv,
'车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间',
[id, createPayload.plateNumber, 'MAINTENANCE'])
}
async function verifyCanonicalPermissionDenial(context, resource) {
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', {
roleCode: `P4_MD_DENY_${suffix}`.slice(0, 64), roleName: `四期主数据拒绝-${suffix}`, description: context.marker,
})
const role = requireData(context, '创建无主数据权限角色', roleResponse)
const roleId = asId(role?.id)
if (!roleId) throw new Error('无主数据权限角色未返回ID')
resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null }
const phone = await context.findAvailablePhone(MODULE)
const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase()
const password = `P4md${suffix.slice(-8)}A1!`
const userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
username, displayName: `四期主数据拒绝-${suffix}`, phone,
email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE',
})
const user = requireData(context, '创建无主数据权限用户', userResponse)
const membershipId = asId(user?.membershipId)
if (!membershipId) throw new Error('无主数据权限用户未返回membershipId')
resource.permissionProbe.membershipId = membershipId
resource.permissionProbe.userStatus = 'ACTIVE'
const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '')
const login = requireData(context, '无主数据权限用户登录', loginResponse)
if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token')
const denied = await context.requestOverseas('GET',
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken)
assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied)
}
async function cleanupPartnerWarehouse(context, resource) {
@@ -355,16 +433,33 @@ async function cleanupVersioned(context, resource, key, path, extract) {
}
}
async function cleanupPermissionProbe(context, resource) {
const probe = resource.permissionProbe
if (!probe) return
if (probe.membershipId && probe.userStatus !== 'DISABLED') {
const response = await context.request('PUT', `/api/platform/v1/iam/users/${probe.membershipId}/status`, { status: 'DISABLED' })
context.apiSuccess(MODULE, '无权限探针用户finally停用', response, `membershipId=${probe.membershipId}`, BLOCKER)
if (success(response)) probe.userStatus = 'DISABLED'
}
if (probe.roleId && probe.roleStatus !== 'DISABLED') {
const response = await context.request('PUT', `/api/platform/v1/iam/roles/${probe.roleId}/status`, { status: 'DISABLED' })
context.apiSuccess(MODULE, '无权限探针角色finally停用', response, `roleId=${probe.roleId}`, BLOCKER)
if (success(response)) probe.roleStatus = 'DISABLED'
}
}
export async function run(context) {
const resource = { marker: context.marker }
context.createdResources.phase4OverseasMasterData = resource
try {
await continueLifecycle(context, '海外报价', async () => {
await captureWarehouseOptionsBeforeCreate(context, resource)
await createAndVerifyPartnerWarehouse(context, resource)
await quoteLifecycle(context, resource)
})
await continueLifecycle(context, '月度汇率', () => exchangeLifecycle(context, resource))
await continueLifecycle(context, '车辆主数据', () => vehicleLifecycle(context, resource))
await continueLifecycle(context, 'canonical权限拒绝', () => verifyCanonicalPermissionDenial(context, resource))
} finally {
await safeCleanup(context, '海外报价', async () => {
if (!resource.quote?.id) return
@@ -382,5 +477,6 @@ export async function run(context) {
await cleanupVersioned(context, resource, 'vehicle', path, (value) => value)
})
await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource))
await safeCleanup(context, 'canonical权限探针', () => cleanupPermissionProbe(context, resource))
}
}
@@ -9,41 +9,79 @@ import { run } from './phase4-overseas-pda.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
test('PDA module uses only injected development acceptance requests', () => {
test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => {
assert.match(source, /export async function run\(context\)/)
assert.match(source, /context\.requestOverseas\(/)
assert.match(source, /context\.requestOverseasMultipart\(/)
assert.doesNotMatch(source, /https?:\/\//)
assert.doesNotMatch(source, /process\.env|fetch\(/)
})
test('PDA module covers the complete receiving lifecycle and excludes outbound domains', () => {
for (const token of ['/destination', '/inbound-plans/convert', '/pallets/', '/push', '/cartons/scan', '/allocations/',
'/count', '/pick', '/unload-photos/files', '/unload-photos', '/complete', '/inventory/balances']) {
assert.ok(source.includes(token), token)
}
for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden)
assert.match(source, /requestOverseas\(/)
assert.match(source, /requestOverseasMultipart\(/)
assert.match(source, /requestId=/)
assert.match(source, /traceId=/)
assert.match(source, /PENDING_ASSIGNMENT/)
assert.match(source, /nonBlank\(item\?\.containerNo\)/)
assert.match(source, /expectedCartons/)
assert.doesNotMatch(source, /https?:\/\//)
assert.doesNotMatch(source, /fetch\(|process\.env/)
assert.doesNotMatch(source, /planNo|allocationId/)
})
test('missing source order is reported as a fixture prerequisite without business writes', async () => {
test('PDA module covers the container-number receiving contract and excludes outbound domains', () => {
for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign',
'/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve',
'/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token)
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.ok(source.includes(role), role)
for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden)
assert.match(source, /同键同载荷重放/)
assert.match(source, /同键异载荷冲突/)
assert.match(source, /跨柜切换锁返回409/)
assert.match(source, /超收被拒绝/)
assert.match(source, /重复箱新键被拒绝/)
assert.match(source, /模式硬拒绝/)
assert.match(source, /current返回404正常空态/)
assert.match(source, /安全扫描被拒绝/)
assert.match(source, /retained as acceptance evidence/)
})
test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => {
const calls = []; const gaps = []; const steps = []
const context = {
marker: 'PDA-CONTRACT', createdResources: {},
async request(method, requestPath) { calls.push({ channel: 'domestic', method, requestPath }); return { http: 200, body: { code: '200', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } } } },
async requestOverseas(method, requestPath) { calls.push({ channel: 'overseas', method, requestPath }); throw new Error('overseas should not be called without customers') },
marker: 'PDA-CONTRACT', context: {}, createdResources: {},
async request(...args) { calls.push(args); throw new Error('must not request without credentials') },
async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') },
async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
addStep(...args) { steps.push(args) },
assert(...args) { steps.push(args) },
assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) },
addCoverageGap(...args) { gaps.push(args) },
}
await run(context)
assert.equal(calls.length, 1)
assert.equal(calls[0].method, 'GET')
assert.equal(calls.length, 0)
assert.equal(gaps.length, 1)
assert.match(gaps[0][3], /三期装柜完成事件链/)
assert.deepEqual(context.createdResources.phase4OverseasPda.finalStatus, null)
assert.equal(gaps[0][5].deploymentBlocker, true)
assert.equal(steps.length, 1)
assert.equal(steps[0].passed, false)
assert.equal(steps[0].options.deploymentBlocker, true)
})
test('empty candidate response cannot become a zero-business-request pass', async () => {
const calls = []; const gaps = []; const steps = []
const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200,
body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' })
const context = {
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {},
context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } },
async request(method, requestPath) {
calls.push({ channel: 'domestic', method, requestPath })
return response({ overseasAccessToken: 'pda-token' }, requestPath, method)
},
async requestOverseas(method, requestPath) {
calls.push({ channel: 'overseas', method, requestPath })
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
return response([], requestPath, method)
},
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
addCoverageGap(...args) { gaps.push(args) },
}
await run(context)
assert.ok(calls.some((call) => call.requestPath.endsWith('/candidates')))
assert.ok(steps.some((step) => step.name.includes('正常空态') && step.passed))
assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker))
assert.equal(gaps.length, 1)
assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null)
})
@@ -1,212 +1,270 @@
const MODULE = 'phase4-overseas-pda'
const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle'
const SCENARIO = 'phase4-overseas-pda-container-receiving'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
function id(value) { return value == null ? '' : String(value) }
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
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 !== undefined && value !== null && value !== '') search.set(key, String(value))
return `${path}?${search}`
}
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function code(response) { return String(response?.body?.code ?? '') }
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) }
function conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) }
function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) }
function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) }
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
async function domesticGet(context, name, path) {
const response = await context.request('GET', path)
const passed = success(response)
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return passed ? response.body.data : null
}
async function overseas(context, method, name, path, body = undefined) {
const response = await context.requestOverseas(method, path, body)
const passed = success(response)
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return { response, data: passed ? response.body.data : null }
function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) {
context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true })
context.assert(MODULE, `${name}覆盖前置`, response, false, reason, OPTIONS)
}
async function findUnplannedOrder(context) {
const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE'))
for (const tenant of tenants) {
const customerId = id(tenant?.id)
if (!longId(customerId)) continue
const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 }))
if (!success(plans.response)) continue
const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean))
const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 }))
if (!success(orders.response)) continue
const order = records(orders.data).find((item) => longId(item?.id) && nonBlank(item?.containerNo)
&& !planned.has(id(item.id)))
if (order) return { customerId, order }
async function pda(context, token, method, name, path, body = undefined) {
const response = await context.requestOverseas(method, path, body, {}, token)
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
return { response, data: success(response) ? response.body?.data : null }
}
async function expected(context, token, method, name, path, body, predicate) {
const response = await context.requestOverseas(method, path, body, {}, token)
context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS)
return response
}
async function loginPda(context) {
const fixture = context.context?.merchantQualificationFixture
if (!fixture?.username || !fixture?.password) {
blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块')
return null
}
return null
const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '')
const data = success(response) ? response.body?.data : null
context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response,
success(response) && nonBlank(data?.overseasAccessToken),
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
}
async function createWarehouse(context, customerId) {
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
const warehouseCode = `PDA-${suffix}`.slice(0, 50)
const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', {
customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS',
countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA',
contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`,
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null,
latitude: null, longitude: null },
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null,
description: `${context.marker} PDA inbound acceptance` }],
async function createPallet(context, inbound, index) {
const warehouseId = id(inbound?.destinationWarehouseId)
if (!longId(warehouseId)) return null
const suffix = markerSuffix(context)
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64),
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`,
})
const warehouseId = id(created.data)
context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId),
`warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS)
return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` }
context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS)
const locationId = id(location?.body?.data)
if (!longId(locationId)) return null
context.createdResources.phase4OverseasPda.locationIds.push(locationId)
const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128)
const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId,
remark: `${context.marker}-receiving-retained`,
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
})
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS)
const palletId = id(pallet?.body?.data)
return longId(palletId) ? { warehouseId, locationId, palletId, palletCode, retained: true } : null
}
async function createResource(context, warehouseId, index, fbaWarehouseCode) {
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase()
const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`,
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64),
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: context.marker,
})
const locationId = id(location.data)
context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId),
`locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS)
const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`,
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2,
unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined,
remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null,
})
const palletId = id(pallet.data)
context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId),
`palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS)
return { locationId, palletId }
}
async function operateExpectedCartons(context, customerId, planNo, task) {
for (const [index, carton] of (task.expectedCartons || []).entries()) {
if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue
const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), {
cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId),
idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response,
scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark,
`cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS)
async function uploadPhotos(context, token) {
const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
}, token)
context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS)
const photos = []
for (const role of PHOTO_ROLES) {
const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG,
}, token)
const fileId = id(response?.body?.data?.fileId)
context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId),
`role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS)
if (!longId(fileId)) return null
photos.push({ role, fileId })
}
return photos
}
async function operateLegacyAllocations(context, customerId, planNo, task) {
for (const [index, allocation] of (task.allocations || []).entries()) {
const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0)
if (remaining <= 0) continue
await overseas(context, 'POST', `清点入仓分配${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), {
packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)),
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
await overseas(context, 'POST', `实收入仓分配${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), {
packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)),
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
function skuFixtures(context) {
const boxes = context.createdResources?.orders?.boxTypes
const orchestration = context.createdResources?.orchestrationFixtures || {}
const markers = [context.marker, orchestration.phase3Loading?.sourceMarker,
orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank)
return Array.isArray(boxes)
? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))]
: []
}
async function receiveFba(context, token, inbound, pallet) {
const skus = skuFixtures(context)
if (!skus.length) {
blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU')
return false
}
let accepted = 0
for (const [index, skuCode] of skus.entries()) {
const resolvedResponse = await context.requestOverseas('POST',
'/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token)
const resolvable = success(resolvedResponse) || notFound(resolvedResponse)
context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS)
const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null }
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue
const packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1
const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode,
packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) }
const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
if (!success(first.response)) return false
accepted += Number(first.data?.acceptedPackages || 0)
const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response,
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
{ ...body, packageCount: packageCount + 1 }, conflict)
await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict)
}
if (accepted <= 0) {
blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`)
return false
}
return true
}
async function receiveOverseas(context, token, inbound, pallet) {
const cartonMark = context.createdResources?.inboundMeasurement?.boxCode
if (!nonBlank(cartonMark)) {
blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号')
return false
}
const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark })
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) {
blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response)
return false
}
const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') }
const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
if (!success(first.response)) return false
const replay = await pda(context, token, 'POST', 'OVERSEAS逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response,
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
{ ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict)
await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
{ ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict)
return true
}
export async function run(context) {
context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null,
planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true }
const source = await findUnplannedOrder(context)
if (!source) {
context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单',
'所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT且具有柜号的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable')
context.createdResources.phase4OverseasPda = {
marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null,
palletIds: [], locationIds: [], finalStatus: null,
cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence',
}
const token = await loginPda(context)
if (!token) return
if (context.overseasToken) {
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
}
const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
const noCurrent = notFound(before)
context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before),
`${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS)
if (success(before)) {
blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before)
return
}
const { customerId, order } = source
const warehouse = await createWarehouse(context, customerId)
if (!longId(warehouse.warehouseId)) return
Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId })
const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓',
query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), {
warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode,
warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`,
})
if (!success(transferred.response)) return
const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', {
orderId: id(order.id), customerId,
})
const plan = converted.data
const planNo = plan?.planNo || ''
context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response,
Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0,
`planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS)
if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return
context.createdResources.phase4OverseasPda.planNo = planNo
for (const [index, planPallet] of plan.pallets.entries()) {
const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode)
if (!longId(resource.locationId) || !longId(resource.palletId)) return
const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), {
warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId,
idempotencyKey: key(context, 'assign', String(index + 1)),
})
if (!success(assigned.response)) return
const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates')
if (!Array.isArray(candidates.data) || candidates.data.length === 0) {
blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response)
return
}
const pushed = await overseas(context, 'POST', '推送PDA入仓任务',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), {
idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
const task = pushed.data
context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response,
longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length
&& Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons),
`taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS)
if (!longId(task?.taskId)) return
context.createdResources.phase4OverseasPda.taskId = id(task.taskId)
if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task)
else await operateLegacyAllocations(context, customerId, planNo, task)
const photo = await context.requestOverseasMultipart('POST',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), {
name: `${context.marker}-unload.png`, type: 'image/png',
content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')),
})
context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS)
const fileId = id(photo?.body?.data?.fileId)
context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS)
if (!longId(fileId)) return
const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), {
fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response,
bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt),
`fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS)
const completed = await overseas(context, 'POST', '完成PDA入仓并写库存',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId }))
context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response,
completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt),
`status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS)
const preferredContainer = context.createdResources?.phase3Loading?.containerNo
const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0]
const containerNo = id(candidate?.containerNo)
context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response,
nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0,
`containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS)
if (!containerNo) return
context.createdResources.phase4OverseasPda.containerNo = containerNo
const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`)
if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return
const prepareBody = { idempotencyKey: key(context, 'prepare') }
const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话',
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
if (!success(prepared.response)) return
const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放',
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response,
success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS)
const other = candidates.data.find((item) => item.containerNo !== containerNo)
if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409',
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict)
else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁')
const photos = await uploadPhotos(context, token)
if (!photos) return
const signBody = { photos, idempotencyKey: key(context, 'sign') }
const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
if (!success(signed.response)) return
const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response,
success(signReplay.response) && signReplay.data?.status === 'SIGNED'
&& PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS)
await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign',
{ ...signBody, photos: [...photos].reverse() }, conflict)
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
if (!success(current.response)) return
const pallets = new Map()
for (const [index, inbound] of current.data.inbounds.entries()) {
const pallet = await createPallet(context, inbound, index)
if (!pallet) {
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
`无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource')
continue
}
pallets.set(inbound.inboundCode, pallet)
context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId)
const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`,
'/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode })
context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response,
success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS)
}
const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA')
const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS')
if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
const firstFba = fbaInbounds[0]
await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA',
palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'),
}, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i))
for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode))
} else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码')
if (overseasInbound && pallets.has(overseasInbound.inboundCode)) {
const pallet = pallets.get(overseasInbound.inboundCode)
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode,
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
}, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i))
await receiveOverseas(context, token, overseasInbound, pallet)
} else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current')
if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) {
blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response)
return
}
const completeBody = { idempotencyKey: key(context, 'complete') }
const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
if (!success(completed.response)) return
const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response,
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS)
const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态',
query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId }))
context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED',
`planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS)
const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存',
query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 }))
context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response,
records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId),
`balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS)
}
@@ -2,7 +2,7 @@ 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'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8')
@@ -33,3 +33,38 @@ test('entry accepts a separate overseas Gateway without exposing token state', (
assert.match(source, /async requestOverseasMultipart\(/)
assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/)
})
test('entry has no built-in environment address or password and requires explicit execution options', () => {
assert.doesNotMatch(source, /8\.163\.|ChangeMe123|DEFAULT_PASSWORD|DEFAULT_GATEWAY/)
assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])),
/缺少必填执行参数/)
const options = parseArguments([
'--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300',
'--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda',
])
assert.equal(validateExecutionOptions(options), options)
})
test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => {
const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300',
username: 'contract-user', password: 'contract-password', modules: [], output: '' })
run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture')
assert.equal(run.steps.at(-1)?.passed, false)
assert.equal(run.steps.at(-1)?.deploymentBlocker, true)
const stepCount = run.steps.length
run.addCoverageGap('phase3', 'device', 'Android', 'manual device validation', 'device')
run.addCoverageGap('phase3', 'provider', 'captcha', 'external captcha', 'external-third-party')
assert.equal(run.steps.length, stepCount)
})
test('PDA entry expands the real phase-three producer chain and builds an FBA/OVERSEAS/second-container matrix', () => {
const modules = parseArguments(['--modules', 'phase4-overseas-pda']).modules
for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence',
'phase3-evidence-gate', 'phase3-loading-finalize']) {
assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda'))
}
assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/)
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/)
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/)
assert.match(source, /readyForPhase4Pda === true/)
})