test: cover overseas pda warehouse operation lifecycles

This commit is contained in:
2026-08-25 11:25:13 +08:00
parent 042d0b738a
commit 68bdc3e6bb
6 changed files with 588 additions and 19 deletions
+14 -9
View File
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
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-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', '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']
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-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', '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']
const MODULE_DEPENDENCIES = new Map([
['orders', ['merchant-qualification-fixture']],
@@ -31,11 +31,12 @@ const MODULE_DEPENDENCIES = new Map([
['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']],
['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']],
['phase4-overseas-pda', ['phase4-carrier-pickup-ready']],
['phase4-overseas-inbound-operations', ['phase4-overseas-pda']],
['phase4-overseas-client-services', ['phase4-overseas-pda']],
['phase4-overseas-dispatch', ['phase4-overseas-pda']],
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']],
['phase4-carrier-nodes', ['phase4-overseas-pda']],
['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']],
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']],
['phase4-carrier-nodes', ['phase4-overseas-pda-operations']],
])
function stableTopologicalOrder(modules) {
@@ -119,10 +120,14 @@ export function normalizeModuleOrder(modules) {
}
for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services',
'phase4-overseas-dispatch']) {
if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda')) {
normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda')
if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda-operations')) {
normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda-operations')
}
}
if (normalized.includes('phase4-overseas-pda')
&& !normalized.includes('phase4-overseas-pda-operations')) {
normalized.splice(normalized.indexOf('phase4-overseas-pda') + 1, 0, 'phase4-overseas-pda-operations')
}
if (normalized.includes('phase4-overseas-pda')) {
const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence',
'phase3-evidence-gate', 'phase3-loading-finalize']
@@ -133,7 +138,7 @@ export function normalizeModuleOrder(modules) {
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',
'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda']
'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda-operations']
const targetIndex = normalized.indexOf('phase4-cross-region-events')
const missing = prerequisites.filter((name) => !normalized.includes(name))
if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing)
+3 -3
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`(客户海外 Token 按柜号执行 candidates/detail/prepare、三角色照片签收、FBA SKU 箱数或 OVERSEAS 逐箱实收、完成与幂等/冲突边界;真实柜、箱唛或托盘前置缺失时 fail-closed) | `phase4-overseas-pda-contract.test.mjs` |
| 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
| 四期海外业务 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,10 +64,10 @@ 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 模块使用本轮客户账号的海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID,也不调用出库、派送或内部接口。收货占用的托盘作为库存事实保留,无法自动取得真实柜、箱唛或托盘时必须形成 coverage gap 和部署阻断,不能按空场景通过;船节点禁止手工创建任务。
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 WAREHOUSE_PICKER 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
```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
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
```
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { run } from './phase4-overseas-pda-operations.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
const receivingSource = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
const entrySource = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
test('PDA operations run immediately after receiving and all later consumers depend on cleanup completion', () => {
const modules = normalizeModuleOrder(['phase4-overseas-client-services'])
const receiving = modules.indexOf('phase4-overseas-pda')
const operations = modules.indexOf('phase4-overseas-pda-operations')
const consumer = modules.indexOf('phase4-overseas-client-services')
assert.ok(receiving >= 0 && operations === receiving + 1 && consumer > operations)
assert.match(entrySource, /'phase4-overseas-pda-operations', \['phase4-overseas-pda'\]/)
for (const module of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services',
'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-carrier-nodes']) {
assert.match(entrySource, new RegExp(`'${module}', \\[[^\\]]*'phase4-overseas-pda-operations'`))
}
})
test('receiving hands off only transient credentials and completed warehouse facts', () => {
assert.match(receivingSource, /context\.context\.phase4OverseasPdaOperations = \{/)
assert.match(receivingSource, /completedFixtures: \[\]/)
assert.match(receivingSource, /if \(!handoffReady\) \{[\s\S]*await cleanup\(\)/)
assert.doesNotMatch(receivingSource, /createdResources\.phase4OverseasPdaOperations/)
assert.doesNotMatch(receivingSource, /primary: primary \? \{[^}]*password/)
assert.doesNotMatch(entrySource.match(/const reportSurface = JSON\.stringify\(\{([\s\S]*?)\n \}\)/)?.[1] || '',
/context/)
})
test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries without outbound domains', () => {
for (const endpoint of [
'/api/pda/v1/last-leg/stocktakes',
'/api/pda/v1/last-leg/inventory-moves',
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve',
'/swap', '/disassemble', '/assemble', '/unbind',
'/api/pda/v1/last-leg/relabel-tasks',
'/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review',
]) assert.ok(source.includes(endpoint), endpoint)
for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409',
'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'APPROVE', 'COMPLETED']) {
assert.ok(source.includes(marker), marker)
}
for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
assert.equal(source.includes(forbidden), false, forbidden)
}
assert.doesNotMatch(source, /fetch\(|https?:\/\//)
assert.match(source, /requestId=/)
assert.match(source, /traceId=/)
})
test('fixture failure still disables both picker accounts and removes all transient tokens', async () => {
const cleanups = []
const gaps = []
const steps = []
const handoff = {
marker: 'PDAOPS-CONTRACT',
primary: { membershipId: '1', username: 'primary', token: 'primary-secret-token' },
secondary: { membershipId: '2', username: 'secondary', token: 'secondary-secret-token' },
completedFixtures: [],
async cleanup() { cleanups.push('secondary', 'primary') },
}
const context = {
marker: 'PDAOPS-CONTRACT', context: { phase4OverseasPdaOperations: handoff },
createdResources: {},
addCoverageGap(...args) { gaps.push(args) },
assert(module, name, response, passed, evidence, options) {
steps.push({ module, name, passed, evidence, options })
},
}
await run(context)
assert.deepEqual(cleanups, ['secondary', 'primary'])
assert.equal(handoff.primary.token, undefined)
assert.equal(handoff.secondary.token, undefined)
assert.equal(context.context.phase4OverseasPdaOperations, undefined)
assert.ok(gaps.some((gap) => gap[2] === 'P2-P5本轮PDA收货交接'))
assert.ok(steps.some((step) => !step.passed && step.options?.deploymentBlocker))
assert.equal(JSON.stringify(context).includes('secret-token'), false)
})
@@ -0,0 +1,442 @@
const MODULE = 'phase4-overseas-pda-operations'
const SCENARIO = 'phase4-overseas-pda-p2-p5'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
function id(value) { return value == null ? '' : String(value) }
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function conflict(response) {
return (response?.http === 409 || (response?.http === 200
&& ['409', 'A0409'].includes(String(response?.body?.code))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] }
function key(context, action) {
return `${context.marker}-PDAOPS-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120)
}
function query(path, values) {
const params = new URLSearchParams()
for (const [name, value] of Object.entries(values || {})) {
if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
}
return params.size ? `${path}?${params}` : path
}
function operationPallet(result, palletCode) {
return (result?.pallets || []).find((item) => item?.palletCode === palletCode)
}
function blocker(context, name, reason, response = null) {
context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true })
context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS)
}
async function request(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 expectConflict(context, token, method, name, path, body) {
const response = await context.requestOverseas(method, path, body, {}, token)
context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS)
return response
}
async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') {
return request(context, token, 'POST', name,
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
async function createLocation(context, warehouseId, suffix) {
const locationCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-64)
const response = await context.requestOverseas('POST',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
zoneCode: 'PDAOPS', locationCode, locationType: 'STORAGE', maxLoadKg: 5000,
status: 'IDLE', remark: `${context.marker}-pda-operations-${suffix}`,
})
const locationId = id(response?.body?.data)
context.addStep(MODULE, `创建${suffix}目标库位`, response,
success(response) && /^\d+$/.test(locationId) && locationId !== '0',
`locationId=${locationId || 'missing'}, locationCode=${locationCode}; ${trace(response)}`, OPTIONS)
return success(response) ? { locationId, locationCode } : null
}
async function createEmptyPallet(context, token, fixture, suffix, destinationWarehouseCode) {
const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120)
const response = await context.requestOverseas('POST',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, {
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE',
locationId: fixture.location.locationId,
fbaWarehouseCode: destinationWarehouseCode || null,
remark: `${context.marker}-pda-operations-${suffix}`,
})
context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS)
if (!success(response)) return null
return (await resolvePallet(context, token, palletCode, `解析${suffix}辅助空托盘`)).data
}
async function verifyStocktake(context, token, fixture) {
const path = '/api/pda/v1/last-leg/stocktakes'
const body = { scopeType: 'PALLET', scopeCode: fixture.pallet.palletCode,
idempotencyKey: key(context, 'STOCKTAKE-CREATE') }
const created = await request(context, token, 'POST', 'P2按PALLET创建盘点任务', path, body)
const taskNo = id(created.data?.task?.taskNo)
if (!taskNo || !Array.isArray(created.data?.items) || created.data.items.length === 0) {
blocker(context, 'P2真实库存盘点', '本轮完成托盘没有可盘点库存位置', created.response)
return false
}
const replay = await request(context, token, 'POST', 'P2创建盘点同键重放', path, body)
context.assert(MODULE, 'P2创建重放复用同一任务', replay.response,
id(replay.data?.task?.taskNo) === taskNo, trace(replay.response), OPTIONS)
await expectConflict(context, token, 'POST', 'P2创建盘点同键异载荷A0409', path,
{ ...body, scopeCode: `${body.scopeCode}-DIFFERENT` })
for (const [index, item] of created.data.items.entries()) {
const countPath = `${path}/${encodeURIComponent(taskNo)}/counts`
const countBody = { positionId: id(item.positionId), actualPackages: Number(item.bookPackages),
expectedVersion: Number(item.version), idempotencyKey: key(context, `STOCKTAKE-COUNT-${index}`) }
const counted = await request(context, token, 'POST', `P2库存项${index + 1}按原数计数`, countPath, countBody)
context.assert(MODULE, `P2库存项${index + 1}零差异`, counted.response,
Number(counted.data?.actualPackages) === Number(item.bookPackages)
&& Number(counted.data?.differencePackages) === 0,
`positionId=${id(item.positionId)}, book=${item.bookPackages}; ${trace(counted.response)}`, OPTIONS)
const countReplay = await request(context, token, 'POST', `P2库存项${index + 1}计数同键重放`, countPath, countBody)
context.assert(MODULE, `P2库存项${index + 1}重放结果稳定`, countReplay.response,
id(countReplay.data?.positionId) === id(item.positionId)
&& Number(countReplay.data?.actualPackages) === Number(item.bookPackages), trace(countReplay.response), OPTIONS)
await expectConflict(context, token, 'POST', `P2库存项${index + 1}计数同键异载荷A0409`, countPath,
{ ...countBody, actualPackages: Number(item.bookPackages) + 1 })
await expectConflict(context, token, 'POST', `P2库存项${index + 1}旧版本A0409`, countPath,
{ ...countBody, idempotencyKey: key(context, `STOCKTAKE-COUNT-STALE-${index}`) })
}
const detail = await request(context, token, 'GET', 'P2回查全部盘点项已计数',
`${path}/${encodeURIComponent(taskNo)}`)
const taskVersion = Number(detail.data?.task?.version)
context.assert(MODULE, 'P2盘点任务计数守恒', detail.response,
detail.data?.task?.status === 'IN_PROGRESS'
&& Number(detail.data?.task?.countedCount) === detail.data?.items?.length
&& detail.data.items.every((item) => Number(item.actualPackages) === Number(item.bookPackages)),
`counted=${detail.data?.task?.countedCount ?? 'missing'}, items=${detail.data?.items?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
await expectConflict(context, token, 'POST', 'P2提交盘点旧任务版本A0409',
`${path}/${encodeURIComponent(taskNo)}/submit`, {
expectedVersion: Math.max(0, taskVersion - 1), reason: `${context.marker}-stale`,
idempotencyKey: key(context, 'STOCKTAKE-SUBMIT-STALE'),
})
const submitBody = { expectedVersion: taskVersion, reason: `${context.marker}-matched`,
idempotencyKey: key(context, 'STOCKTAKE-SUBMIT') }
const submitted = await request(context, token, 'POST', 'P2提交盘点到SUBMITTED',
`${path}/${encodeURIComponent(taskNo)}/submit`, submitBody)
context.assert(MODULE, 'P2盘点提交状态正确且无差异', submitted.response,
submitted.data?.task?.status === 'SUBMITTED' && Number(submitted.data?.task?.differenceCount) === 0,
trace(submitted.response), OPTIONS)
const submitReplay = await request(context, token, 'POST', 'P2提交盘点同键重放',
`${path}/${encodeURIComponent(taskNo)}/submit`, submitBody)
context.assert(MODULE, 'P2提交重放仍为SUBMITTED', submitReplay.response,
submitReplay.data?.task?.status === 'SUBMITTED', trace(submitReplay.response), OPTIONS)
const page = await request(context, token, 'GET', 'P2分页查询已提交盘点',
`${path}?page=1&size=20&status=SUBMITTED`)
context.assert(MODULE, 'P2列表和详情均包含本轮任务', page.response,
records(page.data).some((item) => id(item?.taskNo) === taskNo),
`taskNo=${taskNo}, records=${records(page.data).length}; ${trace(page.response)}`, OPTIONS)
return success(submitted.response)
}
async function verifyInventoryMove(context, token, fixture) {
const target = await createLocation(context, fixture.overseasWarehouseId, 'MOVE')
if (!target) return false
let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data
if (!pallet || !nonBlank(pallet.locationCode)) {
blocker(context, 'P3整托移库', '本轮完成托盘无法解析当前位置')
return false
}
const originalLocationCode = pallet.locationCode
const path = '/api/pda/v1/last-leg/inventory-moves'
const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode,
expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'),
reason: `${context.marker}-move-forward` }
const moved = await request(context, token, 'POST', 'P3整托移到目标库位', path, body)
context.assert(MODULE, 'P3整托移库数量和目标位置正确', moved.response,
moved.data?.status === 'COMPLETED' && moved.data?.targetLocationCode === target.locationCode
&& Number(moved.data?.packageCount) > 0, trace(moved.response), OPTIONS)
const replay = await request(context, token, 'POST', 'P3整托移库同键重放', path, body)
context.assert(MODULE, 'P3移库重放标记且结果稳定', replay.response,
replay.data?.replayed === true && replay.data?.moveNo === moved.data?.moveNo, trace(replay.response), OPTIONS)
await expectConflict(context, token, 'POST', 'P3移库同键异载荷A0409', path,
{ ...body, reason: `${body.reason}-DIFFERENT` })
await expectConflict(context, token, 'POST', 'P3移库旧托盘版本A0409', path, {
...body, targetLocationCode: originalLocationCode, idempotencyKey: key(context, 'MOVE-STALE'),
})
pallet = (await resolvePallet(context, token, pallet.palletCode, 'P3回查托盘已位于目标库位')).data
context.assert(MODULE, 'P3托盘与库存位置同步到目标库位', moved.response,
pallet?.locationCode === target.locationCode && Number(pallet?.totalPackages) > 0,
`location=${pallet?.locationCode || 'missing'}; ${trace(moved.response)}`, OPTIONS)
const restoreBody = { sourcePalletCode: pallet.palletCode, targetLocationCode: originalLocationCode,
expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-RESTORE'),
reason: `${context.marker}-move-restore` }
const restored = await request(context, token, 'POST', 'P3整托移回原库位供后续验证', path, restoreBody)
return success(restored.response) && restored.data?.targetLocationCode === originalLocationCode
}
async function replayOperation(context, token, name, path, body, expectedType) {
const first = await request(context, token, 'POST', name, path, body)
const replay = await request(context, token, 'POST', `${name}同键重放`, path, body)
context.assert(MODULE, `${name}重放结果稳定`, replay.response,
replay.data?.replayed === true && replay.data?.operationId === first.data?.operationId
&& replay.data?.operationType === expectedType,
trace(replay.response), OPTIONS)
return first
}
async function verifyPalletOperations(context, token, fixture) {
const base = '/api/pda/v1/last-leg/pallet-operations'
let main = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P4 resolve主托盘')).data
if (!main || !Array.isArray(main.inventory) || main.inventory.length === 0) {
blocker(context, 'P4托盘操作', '主托盘没有可移动库存行')
return false
}
if (main.inventory.reduce((total, line) => total + Number(line.availablePackages || 0), 0) <= 1) {
blocker(context, 'P4解绑后主托盘可继续验证',
'主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱')
return false
}
const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP', main.destinationWarehouseCode)
const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT', main.destinationWarehouseCode)
const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND', main.destinationWarehouseCode)
if (!swapTarget || !splitTarget || !unbindTarget) {
blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘')
return false
}
const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
target: { palletCode: swapTarget.palletCode, expectedVersion: Number(swapTarget.palletVersion) },
reason: `${context.marker}-swap` }
const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP')
await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`,
{ ...swapBody, reason: `${swapBody.reason}-DIFFERENT` })
let occupiedSwap = operationPallet(swapped.data, swapTarget.palletCode)
|| (await resolvePallet(context, token, swapTarget.palletCode, 'P4换托后解析目标托盘')).data
let emptyMain = operationPallet(swapped.data, main.palletCode)
|| (await resolvePallet(context, token, main.palletCode, 'P4换托后解析原托盘')).data
await expectConflict(context, token, 'POST', 'P4换托旧托盘版本A0409', `${base}/swap`, {
...swapBody, idempotencyKey: key(context, 'PALLET-SWAP-STALE'),
source: { ...swapBody.source }, target: { ...swapBody.target },
})
const swapBackBody = { idempotencyKey: key(context, 'PALLET-SWAP-BACK'),
source: { palletCode: occupiedSwap.palletCode, expectedVersion: Number(occupiedSwap.palletVersion) },
target: { palletCode: emptyMain.palletCode, expectedVersion: Number(emptyMain.palletVersion) },
reason: `${context.marker}-swap-back` }
await replayOperation(context, token, 'P4换托恢复主托盘', `${base}/swap`, swapBackBody, 'PALLET_SWAP')
main = (await resolvePallet(context, token, main.palletCode, 'P4拆托前刷新主托盘')).data
const sourceLine = main.inventory.find((line) => Number(line.availablePackages) > 1)
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
const onePackage = 1
const disassembleBody = { idempotencyKey: key(context, 'PALLET-DISASSEMBLE'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
targets: [{ target: { palletCode: splitTarget.palletCode, expectedVersion: Number(splitTarget.palletVersion) },
lines: [{ positionId: id(sourceLine.positionId), packages: onePackage,
expectedPositionVersion: Number(sourceLine.positionVersion) }] }],
reason: `${context.marker}-disassemble` }
const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘',
`${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE')
const splitOccupied = operationPallet(disassembled.data, splitTarget.palletCode)
|| (await resolvePallet(context, token, splitTarget.palletCode, 'P4拆托后解析辅助托盘')).data
const mainAfterSplit = operationPallet(disassembled.data, main.palletCode)
|| (await resolvePallet(context, token, main.palletCode, 'P4拆托后解析主托盘')).data
const splitLine = splitOccupied.inventory.find((line) => Number(line.availablePackages) > 0)
const assembleBody = { idempotencyKey: key(context, 'PALLET-ASSEMBLE'),
target: { palletCode: mainAfterSplit.palletCode, expectedVersion: Number(mainAfterSplit.palletVersion) },
sources: [{ palletCode: splitOccupied.palletCode, expectedVersion: Number(splitOccupied.palletVersion) }],
lines: [{ positionId: id(splitLine.positionId), packages: Number(splitLine.availablePackages),
expectedPositionVersion: Number(splitLine.positionVersion) }],
reason: `${context.marker}-assemble` }
await replayOperation(context, token, 'P4合托恢复主托盘', `${base}/assemble`, assembleBody, 'PALLET_ASSEMBLE')
main = (await resolvePallet(context, token, main.palletCode, 'P4解绑前刷新主托盘')).data
const unbindSource = main.inventory.find((line) => Number(line.availablePackages) > 1)
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
const unbindPreparedBody = { idempotencyKey: key(context, 'PALLET-UNBIND-PREPARE'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
targets: [{ target: { palletCode: unbindTarget.palletCode, expectedVersion: Number(unbindTarget.palletVersion) },
lines: [{ positionId: id(unbindSource.positionId), packages: 1,
expectedPositionVersion: Number(unbindSource.positionVersion) }] }],
reason: `${context.marker}-unbind-prepare` }
const prepared = await replayOperation(context, token, 'P4拆出一箱作为解绑样本',
`${base}/disassemble`, unbindPreparedBody, 'PALLET_DISASSEMBLE')
const boundAuxiliary = operationPallet(prepared.data, unbindTarget.palletCode)
|| (await resolvePallet(context, token, unbindTarget.palletCode, 'P4解析解绑样本托盘')).data
const unbindBody = { idempotencyKey: key(context, 'PALLET-UNBIND'),
source: { palletCode: boundAuxiliary.palletCode, expectedVersion: Number(boundAuxiliary.palletVersion) },
reason: `${context.marker}-unbind` }
const unbound = await replayOperation(context, token, 'P4解绑辅助托盘库存',
`${base}/unbind`, unbindBody, 'PALLET_UNBIND')
context.assert(MODULE, 'P4主托盘保留可继续验证状态且辅助托盘解绑完成', unbound.response,
unbound.data?.status === 'COMPLETED'
&& operationPallet(unbound.data, boundAuxiliary.palletCode)?.status === 'IDLE',
trace(unbound.response), OPTIONS)
return success(unbound.response)
}
async function verifyRelabel(context, token, fixture) {
const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId)
const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo
|| `PDA-${context.marker}`
const inventory = fixture.inventory.find((item) => nonBlank(item?.skuCode))
if (!customerId || !inventory?.skuCode || !fixture.overseasWarehouseId) {
blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU或海外仓事实')
return false
}
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
const pdaBase = '/api/pda/v1/last-leg/relabel-tasks'
const newSku = `${inventory.skuCode}-NEW-${String(context.marker).slice(-8)}`.slice(0, 128)
const createBody = {
customerOrderNo: String(customerOrderNo).slice(0, 128), overseasWarehouseId: id(fixture.overseasWarehouseId),
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
contactName: 'PDA换标验收', contactPhone: '13800000006', remark: context.marker,
idempotencyKey: key(context, 'RELABEL-CREATE'),
items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品',
totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128),
newSkuCode: newSku, labelRequirement: context.marker }],
}
const createdResponse = await context.requestOverseas('POST',
query(relabelBase, { customerId }), createBody)
const created = success(createdResponse) ? createdResponse.body?.data : null
const relabelNo = id(created?.application?.relabelNo)
context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse,
success(createdResponse) && nonBlank(relabelNo)
&& created?.application?.status === 'PENDING_PROCESSING',
`relabelNo=${relabelNo || 'missing'}, sku=${inventory.skuCode}; ${trace(createdResponse)}`, OPTIONS)
if (!relabelNo) {
blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse)
return false
}
const candidates = await request(context, token, 'GET', 'P5读取当前仓换标候选', `${pdaBase}/candidates`)
context.assert(MODULE, 'P5候选包含本轮真实申请', candidates.response,
Array.isArray(candidates.data) && candidates.data.some((item) => id(item?.relabelNo) === relabelNo),
`relabelNo=${relabelNo}; ${trace(candidates.response)}`, OPTIONS)
const available = await request(context, token, 'GET', 'P5读取换标任务详情',
`${pdaBase}/${encodeURIComponent(relabelNo)}`)
const claimBody = { idempotencyKey: key(context, 'RELABEL-CLAIM') }
const claimed = await request(context, token, 'POST', 'P5认领换标任务',
`${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody)
const claimReplay = await request(context, token, 'POST', 'P5认领换标同键重放',
`${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody)
context.assert(MODULE, 'P5认领重放保持PROCESSING', claimReplay.response,
claimReplay.data?.taskStatus === 'PROCESSING' && claimed.data?.taskStatus === 'PROCESSING',
trace(claimReplay.response), OPTIONS)
let detail = claimed.data || available.data
const item = detail?.items?.[0]
if (!id(item?.itemId)) {
blocker(context, 'P5换标明细', '认领后的换标任务没有真实明细', claimed.response)
return false
}
const unitPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/units`
const scanBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-1`, newLabel: newSku,
expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-NORMAL') }
const scanned = await request(context, token, 'POST', 'P5正常换标一箱', unitPath, scanBody)
const scanReplay = await request(context, token, 'POST', 'P5正常换标同键重放', unitPath, scanBody)
context.assert(MODULE, 'P5正常换标重放不重复计数', scanReplay.response,
Number(scanReplay.data?.relabelledPackages) === 1, trace(scanReplay.response), OPTIONS)
await expectConflict(context, token, 'POST', 'P5正常换标同键异载荷A0409', unitPath,
{ ...scanBody, oldLabel: `${context.marker}-OLD-DIFFERENT` })
await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath,
{ ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') })
detail = scanned.data
const skipBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-2`,
reason: `${context.marker}-skip`, expectedVersion: Number(detail.version),
idempotencyKey: key(context, 'RELABEL-SKIP') }
const skipped = await request(context, token, 'POST', 'P5跳过换标一箱', `${unitPath}/skip`, skipBody)
const skipReplay = await request(context, token, 'POST', 'P5跳过换标同键重放', `${unitPath}/skip`, skipBody)
context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response,
Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS)
detail = skipped.data
const exceptionBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-3`,
exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version),
idempotencyKey: key(context, 'RELABEL-EXCEPTION') }
const exception = await request(context, token, 'POST', 'P5登记换标异常一箱',
`${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody)
const exceptionReplay = await request(context, token, 'POST', 'P5换标异常同键重放',
`${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody)
context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response,
Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS)
detail = exception.data
const photo = await context.requestOverseasMultipart('POST',
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
name: `${context.marker}-relabel.png`, type: 'image/png', content: PNG,
}, token)
const photoId = id(photo?.body?.data?.fileId)
context.addStep(MODULE, 'P5上传换标完成照片', photo,
success(photo) && /^\d+$/.test(photoId) && photoId !== '0',
`fileId=${photoId || 'missing'}; ${trace(photo)}`, OPTIONS)
if (!photoId) return false
const completePath = `${pdaBase}/${encodeURIComponent(relabelNo)}/complete`
const completeBody = { photoFileIds: [photoId], expectedVersion: Number(detail.version),
idempotencyKey: key(context, 'RELABEL-COMPLETE-1'), remark: `${context.marker}-complete-1` }
const completed = await request(context, token, 'POST', 'P5首次完成换标进入WAIT_REVIEW', completePath, completeBody)
const completeReplay = await request(context, token, 'POST', 'P5首次完成同键重放', completePath, completeBody)
context.assert(MODULE, 'P5首次完成重放保持WAIT_REVIEW', completeReplay.response,
completeReplay.data?.taskStatus === 'WAIT_REVIEW', trace(completeReplay.response), OPTIONS)
const reviewPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/review`
const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data?.version),
idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` }
const reworked = await request(context, token, 'POST', 'P5复核退回REWORK', reviewPath, reworkBody)
const reworkReplay = await request(context, token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody)
context.assert(MODULE, 'P5 REWORK重放保持PROCESSING', reworkReplay.response,
reworkReplay.data?.taskStatus === 'PROCESSING', trace(reworkReplay.response), OPTIONS)
const secondCompleteBody = { photoFileIds: [photoId], expectedVersion: Number(reworked.data?.version),
idempotencyKey: key(context, 'RELABEL-COMPLETE-2'), remark: `${context.marker}-complete-2` }
const secondCompleted = await request(context, token, 'POST', 'P5返工后再次完成WAIT_REVIEW',
completePath, secondCompleteBody)
const secondReplay = await request(context, token, 'POST', 'P5再次完成同键重放', completePath, secondCompleteBody)
context.assert(MODULE, 'P5再次完成重放保持WAIT_REVIEW', secondReplay.response,
secondReplay.data?.taskStatus === 'WAIT_REVIEW', trace(secondReplay.response), OPTIONS)
const approveBody = { decision: 'APPROVE', expectedVersion: Number(secondCompleted.data?.version),
idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` }
const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody)
const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody)
context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response,
approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED'
&& approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS)
return success(approved.response)
}
export async function run(context) {
const handoff = context.context?.phase4OverseasPdaOperations
const token = handoff?.primary?.token
try {
if (handoff?.marker !== context.marker || !nonBlank(token)
|| !Array.isArray(handoff.completedFixtures) || handoff.completedFixtures.length === 0) {
blocker(context, 'P2-P5本轮PDA收货交接',
'phase4-overseas-pda未交接本轮primary picker会话与已完成库存事实')
return
}
const fixture = handoff.completedFixtures.find((item) => Array.isArray(item?.inventory)
&& item.inventory.some((line) => Number(line?.availablePackages) > 1))
|| handoff.completedFixtures.find((item) => Array.isArray(item?.inventory)
&& item.inventory.some((line) => Number(line?.availablePackages) > 0))
if (!fixture) {
blocker(context, 'P2-P5可操作库存', '本轮已完成柜没有可移动的真实库存位置')
return
}
fixture.overseasWarehouseId = id(handoff.overseasWarehouseId)
await verifyStocktake(context, token, fixture)
await verifyInventoryMove(context, token, fixture)
await verifyPalletOperations(context, token, fixture)
await verifyRelabel(context, token, fixture)
} finally {
try {
if (typeof handoff?.cleanup === 'function') await handoff.cleanup()
} finally {
if (handoff?.primary) delete handoff.primary.token
if (handoff?.secondary) delete handoff.secondary.token
delete context.context.phase4OverseasPdaOperations
}
}
}
@@ -50,7 +50,15 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
if (mapped) {
context.createdResources.phase4OverseasPda.orderId = id(order.id)
context.createdResources.phase4OverseasPda.customerId = id(order.customerId)
const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}`
const detail = await context.requestOverseas('GET', detailPath)
context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail,
success(detail) && id(detail.body?.data?.order?.id) === id(order.id)
&& Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0,
`orderId=${id(order.id)}, cargo=${detail.body?.data?.cargo?.length ?? 'missing'}; ${trace(detail)}`, OPTIONS)
return success(detail) ? detail.body?.data : null
}
return null
}
async function loginMerchantForNegativeProbe(context) {
@@ -172,6 +180,9 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet) {
&& historyRows.some((item) => String(item?.operationType || '').startsWith('RECEIVING_'))
&& Number(history.data?.page) === 1 && Number(history.data?.size) === 50,
`records=${historyRows.length}; ${trace(history.response)}`, OPTIONS)
return success(inventory.response) && success(location.response) && success(resolvedPallet.response)
? { inboundCode, pallet: resolvedPallet.data, location: location.data, inventory: inventory.data?.items || [] }
: null
}
async function uploadPhotos(context, token) {
@@ -432,10 +443,15 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
const firstInbound = current.data.inbounds.find((item) => pallets.has(item.inboundCode))
if (firstInbound) {
await verifyWarehouseQueries(context, token, firstInbound.inboundCode, pallets.get(firstInbound.inboundCode))
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
pallets.get(inbound.inboundCode))
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
context.context.phase4OverseasPdaOperations.completedFixtures.push({
containerNo, order, ...warehouseFacts,
})
}
}
}
}
@@ -462,6 +478,19 @@ export async function run(context) {
for (const picker of [primary, secondary]) if (picker?.membershipId) {
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
}
let handoffReady = false
const cleanup = async () => {
await disablePicker(context, secondary, 2)
await disablePicker(context, primary, 1)
}
context.context.phase4OverseasPdaOperations = {
marker: context.marker,
primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null,
secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null,
overseasWarehouseId: sharedWarehouse.id,
completedFixtures: [],
cleanup,
}
try {
if (!primary?.token || !secondary?.token) {
blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁')
@@ -486,10 +515,13 @@ export async function run(context) {
context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null)
}
}
handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0
} finally {
delete context.pdaContainerSuffix
await disablePicker(context, secondary, 2)
await disablePicker(context, primary, 1)
if (!handoffReady) {
try { await cleanup() }
finally { delete context.context.phase4OverseasPdaOperations }
}
}
}
@@ -10,6 +10,7 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance
for (const module of [
'phase4-overseas-warehouse',
'phase4-overseas-pda',
'phase4-overseas-pda-operations',
'phase4-overseas-inbound-operations',
'phase4-overseas-client-services',
'phase4-overseas-dispatch',
@@ -27,7 +28,7 @@ test('overseas inbound commands, client services and dispatch follow completed P
'phase4-overseas-dispatch']) {
const modules = parseArguments(['--modules', consumer]).modules
for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading',
'phase3-loading-finalize', 'phase4-overseas-pda']) {
'phase3-loading-finalize', 'phase4-overseas-pda', 'phase4-overseas-pda-operations']) {
assert.ok(modules.indexOf(prerequisite) >= 0
&& modules.indexOf(prerequisite) < modules.indexOf(consumer))
}
@@ -82,4 +83,5 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/)
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/)
assert.match(source, /readyForPhase4Pda === true/)
assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1)
})