test(lastleg): verify return outbound stock and replay invariants

This commit is contained in:
2026-10-02 18:21:55 +08:00
parent 4655090f76
commit fa51d9235c
3 changed files with 270 additions and 54 deletions
+2
View File
@@ -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`,不能用此前未冻结库存的独立出库结果替代。
退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用):
@@ -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)}`
@@ -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')
})