test: cover overseas pda warehouse operation lifecycles
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user