From 68bdc3e6bbeea430456e03f70c70e2756cbf0cca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:25:13 +0800 Subject: [PATCH] test: cover overseas pda warehouse operation lifecycles --- scripts/admin-test-env-acceptance.mjs | 23 +- scripts/admin-test-env-modules/README.md | 6 +- ...-overseas-pda-operations-contract.test.mjs | 88 ++++ .../phase4-overseas-pda-operations.mjs | 442 ++++++++++++++++++ .../phase4-overseas-pda.mjs | 44 +- ...verseas-acceptance-entry-contract.test.mjs | 4 +- 6 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 0c3a005ff..ea82e9925 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 80a224f18..30398eadd 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 --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 --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 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs new file mode 100644 index 000000000..844fecbb7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs new file mode 100644 index 000000000..28696796e --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -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 + } + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b9edf6c8f..e7e7040c1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -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 } + } } } diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 0a1cf2a60..05baae375 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -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) })