import test from 'node:test' 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, 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) test('relabelled outbound refuses another relabel application before creating an outbound', async () => { const calls = [] const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, addStep() {}, assert() {}, async request(method,path) { calls.push({ method, path }) return { http: 200, body: { code: '200', data: path.includes('/candidates') ? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } } } } await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /只出库本轮/) assert.ok(calls.every(call => call.method === 'GET')) }) 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, 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.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.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') }) test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => { const calls = [] const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] } const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, async request(method,path,body,token,_headers) { calls.push({ method, path, body, token }) let data = task if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } } if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletCode: 'OLD', cartonMarks: ['BOX'] }] if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' } if (path.endsWith('/resolve')) { assert.equal(path, '/api/pda/v1/last-leg/pallet-operations/pallets/resolve') data = { palletCode: body.code, palletVersion: 1 } } if (path.endsWith('/relabel-disassemble')) { assert.deepEqual(body.actualSource, { positionId: '12', expectedPositionVersion: 0, packages: 1 }) data = { status: 'COMPLETED', operationId: '9', replayed: true } } if (path.endsWith('/RL-1/complete')) data = { applicationStatus: 'CUSTOMER_CONFIRM_PENDING' } if (path.endsWith('/confirm-completion')) data = { application: { relabelNo: 'RL-1', status: 'COMPLETED' } } if (path.endsWith('/return-orders/RT-1')) data = { order: { status: 'COMPLETED', relabelNo: 'RL-1', relabelStatus: 'COMPLETED' } } return { http: 200, body: { code: '200', data } } }, async requestMultipart(method, path, form, token) { assert.equal(token, 'pda'); assert.ok(form instanceof FormData) calls.push({ method, path }) return { http: 200, body: { code: '200', data: { fileId: '7' } } } } } await completeReturnRelabel(context, 'merchant', 'pda', 'RL-1', 'RT-1') assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelCompleted, true) const index = suffix => calls.findIndex(c => c.path.endsWith(suffix)) assert.ok(index('/status') < index('/claim')) assert.ok(index('/return-skus/complete') < index('/relabel-disassemble')) assert.ok(index('/relabel-disassemble') < index('/photos/files')) assert.ok(index('/photos/files') < index('/RL-1/complete')) assert.ok(index('/RL-1/complete') < index('/confirm-completion')) assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant') }) test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => { const source = await readFile(acceptanceUrl, 'utf8') assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/) assert.match(source, /\['phase4-overseas-return-receipt', \['merchant-qualification-fixture'\]\]/) assert.match(source, /requiresMarkerOwnedMerchantFixture[\s\S]+phase4-overseas-return-receipt/) }) test('return receipt acceptance covers both lookup paths, partial/final receipt and normal empty results', async () => { const source = await readFile(moduleUrl, 'utf8') assert.match(source, /PDA_RECEIPTS = '\/api\/pda\/v1\/last-leg\/return-receipts'/) assert.match(source, /\$\{PDA_RECEIPTS\}\/start/) assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/) assert.match(source, /PDA首次单SKU签收甲3件/) assert.match(source, /PDA按单SKU完成乙2件签收/) assert.match(source, /palletCode: cartonA/) assert.match(source, /最后一批缺少打托照片拒绝且库存不变/) assert.match(source, /photos\/files/) assert.match(source, /requestMultipart\('POST',[^\n]+photoForm, picker\.token\)/) assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/) assert.match(source, /不存在SKU不是业务错误/) assert.match(source, /已完成退货单不再进入待签收流程/) }) test('return receipt acceptance proves idempotency, over-receipt isolation and exact inventory', async () => { const source = await readFile(moduleUrl, 'utf8') assert.match(source, /预报幂等重放复用同一退货单/) assert.match(source, /签收重放不重复创建签收单、箱或计费批次/) assert.match(source, /超收被拒绝且不得生成费用批次/) assert.match(source, /订单5件核平且恰有两个唯一仓储计费批次/) assert.match(source, /两个实收箱逐一引用对应计费批次且数量守恒/) }) test('return receipt acceptance includes fee-batch evidence and return packing lifecycle', async () => { const source = await readFile(moduleUrl, 'utf8') assert.match(source, /billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt'/) assert.match(source, /receiptBatchNos/) assert.match(source, /sourceBatchNo/) assert.match(source, /PDA查询完成退货单的可合箱库存/) assert.match(source, /PDA将同客户退货库存合为新箱/) assert.match(source, /合箱重放复用同一新箱且不重复扣减/) }) test('HTTP acceptance uses injected clients and never bypasses APIs with database access', async () => { const source = await readFile(moduleUrl, 'utf8') assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i) }) test('receipt scope stops before relabel and outbound; followups are explicit and dependency ordered', async () => { const source = await readFile(moduleUrl, 'utf8') assert.doesNotMatch(source, /relabel-candidates|relabel-applications|outbound-orders|outbound-tasks/) const ordered = normalizeModuleOrder(['phase4-overseas-return-followups']) assert.ok(ordered.indexOf('phase4-overseas-return-receipt') < ordered.indexOf('phase4-overseas-return-followups')) assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups')) }) test('return followups use SKU quantities, explicit FBA destination and current file and candidate contracts', async () => { const source = await readFile(new URL('./phase4-overseas-return-followups.mjs', import.meta.url), 'utf8') assert.match(source, /items: \[\{ skuCode: sku, quantity: 1 \}\]/) assert.match(source, /destinationFbaWarehouseId: id\(destination.id\)/) assert.match(source, /destinationFbaWarehouseCode: destination.warehouseCode/) assert.match(source, /status === 'PENDING_REVIEW'/) assert.match(source, /skuCodes\?\.includes\?\.\(sku\)/) assert.match(source, /cartonTotalFileId/) assert.match(source, /customerId, warehouseId: warehouse.id/) assert.doesNotMatch(source, /form, \{\}, merchantToken/) }) test('validation predicate rejects outages, permission failures, success and missing diagnostics', () => { const response = { http: 200, body: { code: 'A0422' }, requestId: 'request', traceId: 'trace' } assert.equal(validationFailure(response), true) for (const code of ['200', 'A0403', 'A0409', 'S0500', 'S0503']) { assert.equal(validationFailure({ ...response, body: { code } }), false) } assert.equal(validationFailure({ ...response, traceId: '' }), false) }) test('inventory snapshot ignores ordering but detects quantity, status and source drift', () => { const a = { cartonMark: 'A', totalQuantity: 2, status: 'AVAILABLE', sourceBatchNo: 'B1', sourceReturnNo: 'R1' } const b = { ...a, cartonMark: 'B', totalQuantity: 3 } const baseline = inventorySnapshot([a, b]) assert.equal(inventorySnapshot({ records: [b, a] }), baseline) for (const change of [{ totalQuantity: 1 }, { status: 'MERGED' }, { sourceBatchNo: 'B2' }, { sourceReturnNo: 'R2' }]) { assert.notEqual(inventorySnapshot([{ ...a, ...change }, b]), baseline) } }) test('extended HTTP coverage includes negative cases and persisted no-mutation verification', async () => { const source = await readFile(moduleUrl, 'utf8') for (const label of ['重复预报SKU', '负实收数量', '未预报SKU', '部分签收退货单禁止合箱', '超可用库存', '失败与幂等重放不改变箱数数量状态及来源', '跨页箱号无重复无遗漏', '合箱及异常后客户SKU仍为甲3乙2']) { assert.ok(source.includes(label), label) } })