diff --git a/scripts/README.md b/scripts/README.md index bccbcc913..8b2661491 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -79,8 +79,12 @@ ADMIN_TEST_USERNAME ADMIN_TEST_PASSWORD MERCHANT_TEST_USERNAME MERCHANT_TEST_PASSWORD +ADMIN_PDA_USERNAME +ADMIN_PDA_PASSWORD ``` +Admin 默认全量及依赖国内装柜链的定向验收必须提供 `ADMIN_PDA_USERNAME/PASSWORD`。管理员用于计划确认,PDA 作业员用于领取与执行;缺少作业员凭据在启动前阻断,不能以管理员身份代替已分配的作业员。 + 脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境: ```powershell diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1b85cad34..d1f6dc084 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -289,6 +289,8 @@ function usage() { --overseas-gateway 四期海外模块必填,海外 Gateway 地址 --username 必填,管理员账号 --password 必填,管理员密码 + --pda-username 国内装柜链必填,实际领取任务的 PDA 作业员账号 + --pda-password 国内装柜链必填,PDA 作业员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --customer-name 指定本轮隔离客户名称,默认自动生成 --merchant-username 复用既有已审核客户账号,不再创建客户 @@ -398,6 +400,10 @@ export function validateExecutionOptions(options) { && (!options.overseasGateway || options.overseasGateway.trim() === '')) { throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') } + if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module)) + && (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) { + throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)') + } if (!Number.isInteger(options.waitSplitCount) || options.waitSplitCount < 1 || options.waitSplitCount > 100) { throw new Error('wait-split-count必须是1-100的整数') } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index ffe084b7b..a5f849664 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -537,28 +537,36 @@ function findIsolatedInbound(context) { } async function multipartRequest(context, path, data) { - const form = new FormData() - form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' })) - form.append('image', new Blob([WEIGHT_IMAGE], { type: 'image/png' }), 'carton-evidence.png') - try { - const response = await fetch(`${context.options.gateway}${path}`, { - method: 'POST', - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, - body: form, - signal: AbortSignal.timeout(60_000), - }) - const content = await response.text() - let body = null - try { body = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ } - return { - method: 'POST', path, http: response.status, body, - raw: body === null ? content.slice(0, 500) : null, - requestId: body?.requestId || response.headers.get('x-request-id'), - traceId: body?.traceId || response.headers.get('x-trace-id'), + let lastResponse = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + const form = new FormData() + form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' })) + form.append('image', new Blob([WEIGHT_IMAGE], { type: 'image/png' }), 'carton-evidence.png') + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, + signal: AbortSignal.timeout(60_000), + }) + const content = await response.text() + let body = null + try { body = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ } + lastResponse = { + method: 'POST', path, http: response.status, body, + raw: body === null ? content.slice(0, 500) : null, + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id'), + } + if (lastResponse.http !== 0 || attempt === 3) return lastResponse + } catch (error) { + lastResponse = { method: 'POST', path, http: 0, body: null, + raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + if (attempt === 3) return lastResponse } - } catch (error) { - return { method: 'POST', path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } + return lastResponse } async function requestWithHeaders(context, method, path, body, extraHeaders) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index a0b62aed3..7527543ac 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -753,7 +753,14 @@ export async function run(context) { } const fixturesByAuxiliaryCapacity = [...handoff.completedFixtures] .sort((left, right) => (right?.auxiliaryPallets?.length || 0) - (left?.auxiliaryPallets?.length || 0)) + // P4/P5 deliberately consumes and rearranges one multi-carton source. Keep the + // ABE8 producer untouched when the matrix contains both destinations so the + // following dispatch module can still observe RECEIVED candidates for ABE4+ABE8. const fixture = fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' + && item?.fbaWarehouseCode === 'ABE4' + && (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory) + && item.inventory.reduce((sum, line) => sum + Number(line?.availablePackages || 0), 0) > 3) + || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' && (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory) && item.inventory.reduce((sum, line) => sum + Number(line?.availablePackages || 0), 0) > 3) || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'OVERSEAS' diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 377c64dc8..3fbdfa1a1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -264,10 +264,19 @@ export async function run(context) { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, }, {}, picker.token)) - await reject('超收被拒绝且不得生成费用批次', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { - returnNo, startedBy: 'SKU', palletCode: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], - idempotencyKey: key(context, 'OVER-RECEIPT'), - }, {}, picker.token)) + await reject('超收被拒绝且不得生成费用批次', conflict, async () => { + const body = { + returnNo, startedBy: 'SKU', palletCode: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], + idempotencyKey: key(context, 'OVER-RECEIPT'), + } + let response = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, body, {}, picker.token) + if (response?.http !== 0 || attempt === 3) break + await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) + } + return response + }) for (const [name, changes, expected] of [ ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure], diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 0c82de1e1..e41b14063 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -131,10 +131,26 @@ test('entry has no built-in environment address or password and requires explici const options = parseArguments([ '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', + '--pda-username', 'contract-picker', '--pda-password', 'contract-picker-password', ]) assert.equal(validateExecutionOptions(options), options) }) +test('loading dependency chains require PDA credentials before making any HTTP requests', () => { + for (const module of ['phase3-loading', 'phase3-container-loading', 'phase5-overseas-fulfillment']) { + const options = parseArguments(['--gateway', 'http://localhost:9000', + '--overseas-gateway', 'http://localhost:9300', '--username', 'admin', '--password', 'fixture', + '--modules', module]) + options.pdaUsername = '' + options.pdaPassword = '' + assert.throws(() => validateExecutionOptions(options), /PDA 作业员凭据/) + options.pdaUsername = 'picker' + assert.throws(() => validateExecutionOptions(options), /PDA 作业员凭据/) + options.pdaPassword = 'fixture' + assert.equal(validateExecutionOptions(options), options) + } +}) + test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => { const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', username: 'contract-user', password: 'contract-password', modules: [], output: '' })