diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 65182cb7f..70aee2b05 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -36,6 +36,8 @@ `phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。 +退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。 + 退货转换标请求按当前契约传 `items[{skuCode, quantity}]`、`fileScope` 和真实 FBA 目的仓 ID/编码,返回 `PENDING_REVIEW` 与 `skuCodes`。分页查找启用的 SCK8,缺失则阻断;总箱标文件回查 `cartonTotalFileId`。独立退货出库候选使用查询参数 `warehouseId`。同模块的 `completeReturnRelabel` 在独立出库之后继续审核本轮换标申请、PDA 认领与数量完成、来源托盘解析、正式生成目标托盘、组托及原键重放、上传合成完工图片、提交完工、客户确认及原键重放,最终回查原退货单仍为签收完成且换标关联一致。它不执行其他客户库存操作;目标托盘是本轮换标业务资源,不是国内基础托盘。客户确认后 `outboundRelabelledReturn` 逐箱核对本轮换标号、客户、仓库、来源单与数量,只为已完成换标的一件库存创建出库,检查创建原键重放、PDA 拣货/完成与平台、客户库存 `OUTBOUNDED` 终态;另存 `relabelOutboundNo`,不能用此前未冻结库存的独立出库结果替代。 退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用): diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index 776c34df1..da71efdfc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -173,28 +173,10 @@ export async function run(context) { nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING', `outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS) if (taskNo) { - const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`) - const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark)) - || task.data?.items?.[0] - const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId, - inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark], - idempotencyKey: key(context, 'OUTBOUND-PICK') } - const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody) - context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response, - picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS) - const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`, - { idempotencyKey: key(context, 'OUTBOUND-COMPLETE') }) - const outboundDetail = await overseas(context, context.token, 'GET', '平台回查退货出库最终状态', - query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })) - context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response, - completed.data?.status === 'COMPLETED' - && outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND' - && outboundDetail.data?.outbound?.status === 'OUTBOUNDED' - && outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark - && Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS) + await verifyReturnOutbound(context, merchantToken, picker.token, { + taskNo, outboundNo, returnNo, warehouseId: warehouse.id, cartonMark: outboundCandidate.cartonMark, + action: 'OUTBOUND', + }) context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo } } @@ -240,29 +222,144 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken, const replay = await command(context.token, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body) require('出库重放保持同一任务和业务单', nonBlank(task.taskNo) && nonBlank(task.outboundNo) && replay.taskNo === task.taskNo && replay.outboundNo === task.outboundNo) - const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}` - const detail = await command(pdaToken, 'GET', 'PDA核对已换标库存出库任务', path) - const frozen = await command(context.token, 'GET', '平台核对本轮冻结出库箱号', - query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) - const items = detail.items || [] - require('出库任务只包含本轮已换标箱', detail.task?.outboundNo === task.outboundNo - && items.length === 1 && Number(items[0].packageCount) === 1 - && frozen.outbound?.outboundNo === task.outboundNo && frozen.pallets?.length === 1 - && frozen.pallets[0].cartonMarks === mark - && id(frozen.pallets[0].id) === id(items[0].outboundPalletItemId)) - await command(pdaToken, 'POST', 'PDA拣取已换标库存', `${path}/pick`, { - outboundPalletItemId: items[0].outboundPalletItemId, inventoryPositionId: null, packages: 1, - cartonMarks: [mark], idempotencyKey: key(context, 'RELABELLED-PICK'), + await verifyReturnOutbound(context, merchantToken, pdaToken, { + taskNo: task.taskNo, outboundNo: task.outboundNo, returnNo, warehouseId, cartonMark: mark, + action: 'RELABELLED', }) - const completed = await command(pdaToken, 'POST', 'PDA完成已换标库存出库', `${path}/complete`, { idempotencyKey: key(context, 'RELABELLED-COMPLETE') }) - const outbound = await command(context.token, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) - const after = await command(merchantToken, 'GET', '客户回查已换标库存出库终态', `${inventory}/${encodeURIComponent(mark)}`) - require('已换标库存出库完成且来源和换标关联不漂移', completed.status === 'COMPLETED' - && outbound.outbound?.status === 'OUTBOUNDED' && after.status === 'OUTBOUNDED' - && after.relabelNo === relabelNo && after.sourceReturnNo === returnNo && Number(after.totalQuantity) === 1) context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo = task.outboundNo } +// Compare persisted business facts, retaining duplicates and ignoring only response ordering. +function facts(value) { + if (Array.isArray(value)) return JSON.stringify(value.map(facts).sort()) + if (value && typeof value === 'object') return JSON.stringify(Object.fromEntries( + Object.keys(value).sort().map(name => [name, facts(value[name])]))) + return value +} + +export async function verifyReturnOutbound(context, merchantToken, pdaToken, { + taskNo, outboundNo, returnNo, warehouseId, cartonMark, action, +}) { + const customerId = id(context.context.merchantQualificationFixture.tenantId) + const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}` + const platform = '/api/platform/v1/last-leg/return-inventory' + const outboundPath = query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }) + const require = (name, passed, evidence = '') => { + context.assert(MODULE, name, null, Boolean(passed), `outboundNo=${outboundNo}; ${evidence}`, OPTIONS) + if (!passed) throw new Error(name) + } + const command = async (token, method, name, url, body) => { + const result = await overseas(context, token, method, name, url, body) + if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`) + return result.data + } + const pageAll = async (url) => { + const rows = [] + let expectedTotal + for (let page = 1; page <= 100; page += 1) { + const data = await command(context.token, 'GET', '读取本轮客户仓库退货记账事实', + query(url, { customerId, overseasWarehouseId: warehouseId, page, size: 200 })) + const total = Number(data?.total) + require('退货记账分页完整且作用域明确', Array.isArray(data?.records) && Number.isSafeInteger(total) + && total >= 0 && (expectedTotal === undefined || expectedTotal === total) + && data.records.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId)) + expectedTotal = total + rows.push(...data.records) + require('退货记账分页无溢出或空页截断', rows.length <= total && (rows.length === total || data.records.length > 0)) + if (rows.length === total) return rows + } + throw new Error('退货记账分页超过安全上限,不能以部分结果验收') + } + // Only these three stock counters are normalized; IDs and other string facts stay exact. + const normalizeBalances = rows => rows.map(row => { + const normalized = { ...row } + for (const field of ['totalQuantity', 'availableQuantity', 'cartonQuantity']) { + const value = row[field] + require(`库存${field}必须为非负安全整数`, + (typeof value === 'number' || (typeof value === 'string' && /^(0|[1-9][0-9]*)$/.test(value))) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0) + normalized[field] = Number(value) + } + return normalized + }) + const snapshot = async () => ({ + balances: normalizeBalances(await pageAll(platform)), + movements: await pageAll(`${platform}/movements`), + carton: await command(merchantToken, 'GET', '客户回查退货历史箱明细', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(cartonMark)}`), + task: await command(pdaToken, 'GET', 'PDA回查退货出库事实', path), + outbound: await command(context.token, 'GET', '平台回查退货出库事实', outboundPath), + }) + const stockFacts = value => facts({ balances: value.balances, movements: value.movements, carton: value.carton }) + const before = await snapshot() + const carton = before.carton + require('出库箱历史明细归属及SKU数量有效', carton?.cartonMark === cartonMark && carton.customerId === customerId + && carton.overseasWarehouseId === warehouseId && carton.sourceReturnNo === returnNo && carton.status === 'AVAILABLE' + && carton.items?.length > 0 && new Set(carton.items.map(row => row.skuCode)).size === carton.items.length + && Number(carton.skuTypes) === carton.items.length + && carton.items.every(row => nonBlank(row.skuCode) && Number.isSafeInteger(Number(row.quantity)) && Number(row.quantity) > 0) + && carton.items.reduce((sum, row) => sum + Number(row.quantity), 0) === Number(carton.totalQuantity)) + const items = before.task?.items || [] + const item = items[0] + const position = item?.positions?.[0] + const frozen = before.outbound + require('PDA必须返回真实退货箱扫描位置且不得回退首行或伪造字段', items.length === 1 + && before.task.task?.taskNo === taskNo && before.task.task?.outboundNo === outboundNo + && item.positions?.length === 1 && nonBlank(id(item.outboundPalletItemId)) + && item.inboundCode === returnNo && Number(item.packageCount) === 1 && Number(item.pickedPackageCount) === 0 + && position.cartonMark === cartonMark && position.inboundCode === returnNo + && (position.inventoryPositionId === null || position.inventoryPositionId === undefined) && Number(position.availablePackages) === 1 && Number(position.pickedPackages) === 0 + && frozen.outbound?.outboundNo === outboundNo && frozen.outbound?.outboundType === 'RETURN_OUTBOUND' + && frozen.pallets?.length === 1 && frozen.pallets[0].cartonMarks === cartonMark + && id(frozen.pallets[0].id) === id(item.outboundPalletItemId)) + require('新出库单尚无OUTBOUND流水', !before.movements.some(row => row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo)) + const quantities = new Map(carton.items.map(row => [row.skuCode, Number(row.quantity)])) + require('待出库SKU库存唯一且足够', new Set(before.balances.map(row => row.skuCode)).size === before.balances.length + && [...quantities].every(([sku, quantity]) => before.balances.some(row => row.skuCode === sku + && Number(row.totalQuantity) >= quantity && Number(row.availableQuantity) >= quantity && Number(row.cartonQuantity) >= 1))) + const pickBody = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: null, + packages: 1, cartonMarks: [position.cartonMark], idempotencyKey: key(context, `${action}-PICK`) } + await command(pdaToken, 'POST', 'PDA扫描真实退货箱', `${path}/pick`, pickBody) + const picked = await snapshot() + const expectedBalances = before.balances.map(row => { + const quantity = quantities.get(row.skuCode) + return quantity === undefined ? row : { ...row, totalQuantity: Number(row.totalQuantity) - quantity, + availableQuantity: Number(row.availableQuantity) - quantity, cartonQuantity: Number(row.cartonQuantity) - 1 } + }) + const withoutTime = rows => rows.map(({ updatedAt, ...row }) => row) + require('逐SKU扣减实际件数与一个可用箱且其他SKU不变', facts(withoutTime(picked.balances)) === facts(withoutTime(expectedBalances)), + JSON.stringify({ before: before.balances, after: picked.balances, quantities: [...quantities] })) + const outboundMovements = picked.movements.filter(row => row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo) + require('每个出库SKU只有一条等量负OUTBOUND流水且历史流水不变', outboundMovements.length === quantities.size + && [...quantities].every(([sku, quantity]) => outboundMovements.filter(row => row.skuCode === sku + && Number(row.totalDelta) === -quantity && Number(row.availableDelta) === -quantity).length === 1) + && facts(picked.movements.filter(row => !(row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo))) === facts(before.movements), + JSON.stringify(outboundMovements)) + require('出库后历史箱明细与来源保留', facts(picked.carton) === facts({ ...carton, status: 'OUTBOUNDED' })) + require('拣货任务和单箱进度持久化', picked.task.task?.status === 'IN_PROGRESS' + && picked.task.items?.length === 1 && picked.task.items[0].status === 'PICKED' + && Number(picked.task.items[0].pickedPackageCount) === 1 + && Number(picked.task.items[0].positions?.[0]?.availablePackages) === 0) + await command(pdaToken, 'POST', '退货拣货同键同载荷重放', `${path}/pick`, pickBody) + require('同键重放后全部记账与任务事实不变', facts(await snapshot()) === facts(picked)) + for (const [label, body] of [ + ['同键错误箱唛', { ...pickBody, cartonMarks: [`${cartonMark}-WRONG`] }], + ['同键错误件数', { ...pickBody, packages: 2 }], + ['新键重复拣货', { ...pickBody, idempotencyKey: key(context, `${action}-PICK-AGAIN`) }], + ]) { + const rejected = await context.request('POST', `${path}/pick`, body, pdaToken, {}) + add(context, `${label}拒绝`, rejected, conflict(rejected)) + require(`${label}必须返回带诊断的冲突`, conflict(rejected), trace(rejected)) + require(`${label}后全部事实不变`, facts(await snapshot()) === facts(picked)) + } + const completeBody = { idempotencyKey: key(context, `${action}-COMPLETE`) } + await command(pdaToken, 'POST', 'PDA完成退货出库', `${path}/complete`, completeBody) + const completed = await snapshot() + require('完成退货出库不二次扣库存且历史箱明细流水保留', stockFacts(completed) === stockFacts(picked) + && completed.task.task?.status === 'COMPLETED' && completed.outbound.outbound?.status === 'OUTBOUNDED') + await command(pdaToken, 'POST', '退货出库完成同键重放', `${path}/complete`, completeBody) + require('完成重放后全部事实不变', facts(await snapshot()) === facts(completed)) +} + export async function completeReturnRelabel(context, merchantToken, pdaToken, relabelNo, returnNo) { const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index ba64512e8..0d5799df5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { completeReturnRelabel, outboundRelabelledReturn } from './phase4-overseas-return-followups.mjs' +import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound } from './phase4-overseas-return-followups.mjs' const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) @@ -21,25 +21,142 @@ test('relabelled outbound refuses another relabel application before creating an assert.ok(calls.every(call => call.method === 'GET')) }) -test('return outbound binds platform frozen carton to PDA item without requiring ordinary inventory positions', async () => { +function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true) { + const calls = [] + let picked = false, completed = false, duplicate = false, drift = false const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, - async request(method,path,body) { + async request(method, rawPath, body, token) { + calls.push({ method, path: rawPath, body, token }) + const url = new URL(rawPath, 'https://offline.invalid'), path = url.pathname let data - if (path.includes('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] - else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', - overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', - status: this.done ? 'OUTBOUNDED' : 'AVAILABLE', totalQuantity: 1, items: [{ skuCode: 'SKU', quantity: 1 }] } - else if (path.includes('/outbound-orders/return?')) data = { taskNo: 'TASK', outboundNo: 'OUT' } - else if (path.endsWith('/complete')) { this.done = true; data = { status: 'COMPLETED' } } - else if (path.endsWith('/pick')) { - assert.deepEqual(body.cartonMarks, ['BOX']); assert.equal(body.outboundPalletItemId, '9') + if (path.endsWith('/pick')) { + if (!picked) { + assert.deepEqual(body.cartonMarks, ['BOX']); assert.equal(body.outboundPalletItemId, '9') + assert.equal(body.inventoryPositionId, null); assert.equal(body.packages, 1); picked = true + } else if (body.packages !== 1 || body.cartonMarks[0] !== 'BOX' || body.idempotencyKey.endsWith('AGAIN')) { + if (fault === 'rejected write') drift = true + return { http: 200, body: { code: fault === 'accept wrong payload' ? '200' : 'A0409' }, + requestId: 'request', traceId: fault === 'missing diagnostics' ? '' : 'trace' } + } else if (fault === 'replay deducts') duplicate = true data = { status: 'IN_PROGRESS' } - } else if (path.includes('/outbound-orders/OUT?')) data = { outbound: { outboundNo: 'OUT', status: this.done ? 'OUTBOUNDED' : 'WAIT_PICK' }, pallets: [{ id: '9', cartonMarks: 'BOX' }] } - else data = { task: { outboundNo: 'OUT' }, items: [{ outboundPalletItemId: '9', packageCount: 1, positions: [] }] } + } else if (path.endsWith('/complete')) { completed = true; data = { status: 'COMPLETED' } } + else if (path.endsWith('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] + else if (path.endsWith('/outbound-orders/return')) data = { taskNo: 'TASK', outboundNo: 'OUT' } + else if (path.endsWith('/return-inventory')) { + assert.equal(url.searchParams.get('customerId'), '123'); assert.equal(url.searchParams.get('overseasWarehouseId'), '42') + let deducted = picked ? (fault === 'package count deduction' ? 1 : quantity) : 0 + if (duplicate || (completed && fault === 'complete deducts')) deducted += quantity + const stock = { customerId: fault === 'wrong scope' ? 'other' : '123', overseasWarehouseId: '42', skuCode: 'SKU', + totalQuantity: quantity - deducted, availableQuantity: quantity - deducted, + cartonQuantity: picked && fault !== 'carton not deducted' ? 0 : 1, updatedAt: picked ? 'after' : 'before' } + const unrelated = { ...stock, skuCode: 'OTHER', totalQuantity: drift ? 8 : 9, availableQuantity: 9, cartonQuantity: 1 } + data = { total: fault === 'truncated page' ? 3 : 2, records: url.searchParams.get('page') === '1' ? [stock] + : url.searchParams.get('page') === '2' ? [unrelated] : [] } + } else if (path.endsWith('/movements')) { + const receipt = { customerId: '123', overseasWarehouseId: '42', skuCode: 'SKU', movementType: 'RECEIPT', referenceNo: 'RECEIPT', totalDelta: quantity, availableDelta: quantity, occurredAt: 'before' } + const movement = { ...receipt, movementType: 'OUTBOUND', referenceNo: 'OUT', totalDelta: -quantity, + availableDelta: fault === 'wrong movement quantity' ? -1 : -quantity, occurredAt: 'after' } + const rows = [receipt, ...(picked && fault !== 'missing movement' ? [movement] : [])] + if (picked && fault === 'duplicate movement') rows.push(movement) + data = { records: rows.reverse(), total: rows.length } + } else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', + overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', + status: picked ? 'OUTBOUNDED' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity, + receivedAt: picked && fault === 'history time drift' ? '2026-10-02T17:54:38.305' : '2026-10-02T17:52:56.794', + items: [{ skuCode: 'SKU', quantity: picked && fault === 'history erased' ? 0 : quantity }] } + else if (path.endsWith('/outbound-orders/OUT')) data = { outbound: { outboundNo: 'OUT', outboundType: 'RETURN_OUTBOUND', status: completed ? 'OUTBOUNDED' : 'WAIT_PICK' }, pallets: [{ id: '9', cartonMarks: 'BOX', pickedPackageCount: picked ? 1 : 0 }] } + else if (path.endsWith('/outbound-tasks/TASK')) data = { task: { taskNo: 'TASK', outboundNo: 'OUT', status: completed ? 'COMPLETED' : picked ? 'IN_PROGRESS' : 'PENDING' }, + items: [{ outboundPalletItemId: '9', inboundCode: 'RT-1', packageCount: 1, pickedPackageCount: picked ? 1 : 0, + status: picked ? 'PICKED' : 'PENDING', positions: fault === 'missing positions' ? [] : [{ inventoryPositionId: null, + cartonMark: fault === 'wrong PDA carton' ? 'OTHER' : 'BOX', inboundCode: 'RT-1', availablePackages: picked ? 0 : 1, pickedPackages: picked ? 1 : 0 }] }] } + else throw new Error(`unexpected request: ${method} ${rawPath}`) + if (multiSku && path.endsWith('/return-inventory')) { + data.total += 1 + if (url.searchParams.get('page') === '2') data.records.push({ customerId: '123', overseasWarehouseId: '42', + skuCode: 'SKU-B', totalQuantity: picked ? 0 : 2, availableQuantity: picked ? 0 : 2, cartonQuantity: picked ? 0 : 1 }) + } + if (multiSku && path.includes('/return-inventory/cartons/')) { + data.skuTypes = 2; data.totalQuantity += 2; data.items.push({ skuCode: 'SKU-B', quantity: 2 }) + } + if (multiSku && picked && path.endsWith('/movements') && fault !== 'second SKU movement missing') { + data.records.push({ customerId: '123', overseasWarehouseId: '42', skuCode: 'SKU-B', movementType: 'OUTBOUND', + referenceNo: 'OUT', totalDelta: -2, availableDelta: -2, occurredAt: 'after' }); data.total += 1 + } + if (wire) { + if ('total' in data) data.total = String(data.total) + if (path.endsWith('/return-inventory')) for (const row of data.records) { + for (const field of ['totalQuantity', 'availableQuantity', 'cartonQuantity']) row[field] = String(row[field]) + } + if (path.endsWith('/movements')) for (const row of data.records) { + row.totalDelta = String(row.totalDelta); row.availableDelta = String(row.availableDelta) + } + if (path.includes('/return-inventory/cartons/')) { + data.totalQuantity = String(data.totalQuantity) + for (const row of data.items) row.quantity = String(row.quantity) + } + if (path.endsWith('/outbound-tasks/TASK')) for (const item of data.items) for (const position of item.positions) { + delete position.inventoryPositionId + position.availablePackages = String(position.availablePackages); position.pickedPackages = String(position.pickedPackages) + } + } + if (path.endsWith('/return-inventory') && fault && typeof fault === 'object' && data.records.length) { + data.records[0][fault.field] = fault.value + } + if (path.endsWith('/outbound-tasks/TASK') && fault === 'nonempty position') data.items[0].positions[0].inventoryPositionId = '9007199254740993' + if (path.endsWith('/return-inventory') && picked && fault === 'identifier drift') { + for (const row of data.records) row.customerId = 123 + } return { http: 200, body: { code: '200', data } } } } + return { context, calls } +} +const outboundInput = { taskNo: 'TASK', outboundNo: 'OUT', returnNo: 'RT-1', warehouseId: '42', cartonMark: 'BOX', action: 'OUTBOUND' } + +test('return outbound proves one box five units accounting history replay and completion using current DTOs', async () => { + const { context, calls } = outboundHarness() + await verifyReturnOutbound(context, 'merchant', 'pda', outboundInput) + assert.equal(calls.filter(c => c.path.endsWith('/pick')).length, 5) + assert.equal(calls.filter(c => c.path.endsWith('/complete')).length, 2) + assert.ok(calls.some(c => c.path.includes('page=2'))) + assert.ok(calls.filter(c => c.method === 'POST').every(c => c.token === 'pda')) +}) + +for (const fault of ['package count deduction', 'carton not deducted', 'missing movement', 'duplicate movement', + 'wrong movement quantity', 'history erased', 'replay deducts', 'rejected write', 'accept wrong payload', + 'missing diagnostics', 'complete deducts', 'wrong scope', 'truncated page', 'missing positions', 'wrong PDA carton', + 'history time drift', 'nonempty position', 'identifier drift']) { + test(`return outbound fails closed on ${fault}`, async () => { + const { context, calls } = outboundHarness(fault) + await assert.rejects(verifyReturnOutbound(context, 'merchant', 'pda', outboundInput)) + if (['wrong scope', 'truncated page', 'missing positions', 'wrong PDA carton', 'nonempty position'].includes(fault)) assert.ok(calls.every(c => c.method === 'GET')) + if (fault !== 'complete deducts') assert.ok(!calls.some(c => c.path.endsWith('/complete'))) + }) +} + +test('explicit null positions and numeric counters remain accepted', async () => { + await verifyReturnOutbound(outboundHarness('', 5, false, false).context, 'merchant', 'pda', outboundInput) +}) + +for (const field of ['totalQuantity', 'availableQuantity', 'cartonQuantity']) { + test(`stock ${field} rejects malformed or unsafe integer values before picking`, async () => { + for (const value of ['', ' ', '1.5', '1e2', '0x10', '-1', '01', '9007199254740993', null, undefined, true, 1.5, -1, NaN, Infinity, 9007199254740992]) { + const { context, calls } = outboundHarness({ field, value }) + await assert.rejects(verifyReturnOutbound(context, 'merchant', 'pda', outboundInput), /必须为非负安全整数/) + assert.ok(calls.every(call => call.method === 'GET')) + } + }) +} + +test('mixed SKU carton verifies each SKU quantity and one carton deduction per SKU', async () => { + await verifyReturnOutbound(outboundHarness('', 5, true).context, 'merchant', 'pda', outboundInput) + await assert.rejects(verifyReturnOutbound(outboundHarness('second SKU movement missing', 5, true).context, + 'merchant', 'pda', outboundInput), /每个出库SKU只有一条/) +}) + +test('relabelled outbound shares accounting and strict PDA scan checks', async () => { + const { context } = outboundHarness('', 1) await outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42') assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo, 'OUT') })