test(lastleg): verify return outbound stock and replay invariants
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user