import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js' import { session } from '../src/stores/session.js' function transport(reply) { const calls = [] session.apiBase = '/api'; session.token = 'overseas-token' globalThis.uni = { request(options) { calls.push({ url: options.url, data: options.data }) if (reply instanceof Error) return options.fail({ errMsg: reply.message }) options.success({ statusCode: 200, data: { code: '200', data: options.url.endsWith('/preparations') ? reply : { success: true } }, header: {} }) } } return calls } test('all stock scan modes require ready preparation with the original command key and source identifiers', async () => { const calls = transport({ status: 'READY', ready: true }) const request = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 2, cartonMark: 'RC-1', productQuantity: 7, idempotencyKey: 'original-dispatch-key' } await pickDispatchItem('DSP-1', '9007199254740995', request) assert.deepEqual(calls[0].data, { mode: 'DISPATCH', businessNo: 'DSP-1', itemId: '9007199254740995', positionId: request.positionId, packages: 2, cartonMark: 'RC-1', productQuantity: 7, idempotencyKey: request.idempotencyKey }) assert.deepEqual(calls[1].data, request) await pickOutboundTask('TASK-1', { outboundPalletItemId: '9007199254740996', inventoryPositionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'original-sku-key' }) assert.deepEqual(calls[2].data, { mode: 'OUTBOUND', businessNo: 'TASK-1', itemId: '9007199254740996', positionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'original-sku-key' }) await pickOutboundPallet('TASK-1', 'DP-1', { idempotencyKey: 'original-pallet-key' }) assert.deepEqual(calls[4].data, { mode: 'OUTBOUND_PALLET', businessNo: 'TASK-1', palletNo: 'DP-1', idempotencyKey: 'original-pallet-key' }) assert.equal(calls.length, 6) for (let i = 0; i < calls.length; i += 2) assert.ok(calls[i].url.endsWith('/preparations')) }) test('carton outbound preparation uses the same identity as the backend stock command', async () => { const calls = transport({ status: 'READY', ready: true }) const request = { outboundPalletItemId: '21', inventoryPositionId: null, packages: 1, cartonMarks: ['RETURN-BOX-1'], idempotencyKey: 'carton-key' } await pickOutboundTask('TASK-2', request) assert.deepEqual(calls[0].data.cartonMarks, request.cartonMarks) // 出库后台消费只使用 cartonMarks,不能额外派生 cartonMark 导致幂等摘要不一致。 assert.equal(Object.hasOwn(calls[0].data, 'cartonMark'), false) assert.deepEqual(calls[1].data, request) }) test('pending, failed, expired, malformed and network responses never send a stock mutation', async () => { for (const response of [{ status: 'FREEZE_PENDING', ready: false }, { status: 'FAILED', message: '余额及授信不足' }, { status: 'EXPIRED', ready: false }, { status: 'READY', ready: false }, { status: 'UNKNOWN', ready: true }, {}, new Error('network down')]) { const calls = transport(response) await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', { idempotencyKey: 'stable-key' }), error => error.storageRentPreparation === true) assert.equal(calls.length, 1) } }) test('retry reuses the preparation key; exempt and already consumed commands can recover the stock result', async () => { const request = { idempotencyKey: 'stable-key' } let calls = transport({ status: 'FREEZE_PENDING', ready: false }) await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', request)) const original = calls[0].data for (const status of ['NOT_REQUIRED', 'READY', 'CONSUMED']) { calls = transport({ status, ready: true }) await pickOutboundPallet('TASK-1', 'DP-1', request) assert.deepEqual(calls[0].data, original) assert.equal(calls.length, 2) } const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8') assert.match(page, /!cause\.storageRentPreparation/) }) test('invalid dispatch version or missing command identity cannot start a reservation', async () => { const calls = transport({ status: 'READY', ready: true }) await assert.rejects(pickDispatchItem('DSP-1', '11', { idempotencyKey: 'key' }), /版本/) await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', {}), /标识/) assert.equal(calls.length, 0) }) test('discard requires funds before stock confirmation and preserves the complete source payload on retry', async () => { const lines = [{ itemId: '9007199254740993', actualPackages: 2 }] const submit = () => confirmCargoDiscard('CD-1', 'P-1', lines, ['101'], ['BOX-1'], false, { idempotencyKey: 'discard-stable-key' }) let calls = transport({ status: 'FREEZE_PENDING', ready: false }) await assert.rejects(submit(), error => error.storageRentPreparation === true) assert.equal(calls.length, 1) const original = calls[0].data assert.deepEqual(original, { mode: 'DISCARD', businessNo: 'CD-1', palletNo: 'P-1', lines, photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-stable-key' }) calls = transport({ status: 'READY', ready: true }) await submit() assert.deepEqual(calls[0].data, original) assert.equal(calls.length, 2) assert.ok(calls[1].url.endsWith('/cargo-discards/CD-1/pallets/P-1/confirm')) assert.deepEqual(calls[1].data, { lines, photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-stable-key' }) })