import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js' import { completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask, listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask, pickDispatchItem, pickOutboundTask } from '../src/api/fulfillment.js' import { session } from '../src/stores/session.js' const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') function storageMock() { const values = new Map() globalThis.uni = { setStorageSync(key, value) { values.set(key, structuredClone(value)) }, getStorageSync(key) { return structuredClone(values.get(key)) }, removeStorageSync(key) { values.delete(key) } } return values } test('phase-five workbench registers only dispatch and outbound PDA delivery boundaries', async () => { const [workbench, pages] = await Promise.all([read('../src/data/overseas-workbench.js'), read('../src/pages.json')]) assert.match(workbench, /name: '出库履约'/) assert.match(workbench, /key: 'dispatchPicking'[\s\S]*available: true[\s\S]*route: '\/pages\/dispatch\/dispatch'/) assert.match(workbench, /key: 'outboundPicking'[\s\S]*available: true[\s\S]*route: '\/pages\/outbound\/outbound'/) assert.match(pages, /pages\/dispatch\/dispatch/) assert.match(pages, /pages\/outbound\/outbound/) for (const excluded of ['派送单', '派送状态', '客户回访']) assert.doesNotMatch(workbench, new RegExp(excluded)) }) test('fulfillment API uses dedicated PDA routes and never sends customer or warehouse scope', async () => { const api = await read('../src/api/fulfillment.js') for (const path of ['dispatches', 'outbound-tasks', '/pick', '/complete', '/depart']) assert.match(api, new RegExp(path.replace('/', '\\/'))) for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden)) assert.match(api, /encodeURIComponent/) }) test('fulfillment API calls the frozen list detail pick complete and depart routes with exact payloads', async () => { const calls = [] session.apiBase = '/overseas-api'; session.token = 'overseas-token' globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) let data = {} if (options.method === 'GET' && /\/dispatches(?:\?|$)/.test(options.url)) data = [] else if (options.method === 'GET' && /\/outbound-tasks(?:\?|$)/.test(options.url)) data = [] else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] } else if (options.method === 'GET' && options.url.includes('/outbound-tasks/')) data = { task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1' }, items: [] } options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } } await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1') await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' }) await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' }) await departDispatch('DSP/1', { idempotencyKey: 'depart-key' }) await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1') await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' }) await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' }) assert.equal(calls.length, 9) assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING'))) assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick'))) assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart'))) assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pick'))) assert.ok(calls.every((call) => call.authorization === 'Bearer overseas-token')) assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url))) for (const call of calls) { assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false) assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false) } const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/complete')) assert.deepEqual(dispatchComplete.data, { idempotencyKey: 'complete-key' }) }) test('dispatch and outbound adapters retain stable business identifiers and count progress', () => { const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ id: 9, availablePackages: 5, pickedPackages: 2 }] }] }) assert.equal(dispatch.dispatchNo, 'DSP-1') assert.equal(dispatch.items[0].palletNo, 'PLT-1') assert.equal(dispatch.items[0].positions[0].availablePackages, 5) const outbound = normalizeOutboundTask({ task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1', status: 'PENDING' }, items: [{ outboundPalletItemId: 3, palletNo: 'PLT-2', skuCode: 'SKU-1', inboundCode: 'INB-1', packageCount: 4, pickedPackageCount: 1, status: 'PARTIAL_PICKED', positions: [{ inventoryPositionId: 7, palletNo: 'PLT-2', cartonMark: 'BOX-1', skuCode: 'SKU-1', inboundCode: 'INB-1', availablePackages: 3, pickedPackages: 1 }] }] }) assert.equal(outbound.taskNo, 'OUT-TASK-1') assert.equal(outbound.items[0].palletNo, 'PLT-2') assert.equal(outbound.items[0].outboundPalletItemId, '3') assert.equal(outbound.items[0].plannedPackages, 4) assert.equal(outbound.items[0].pickedPackages, 1) assert.equal(outbound.items[0].positions[0].cartonMark, 'BOX-1') assert.equal(outbound.items[0].positions[0].availablePackages, 3) }) test('draft restoration scopes work to the signed-in operator warehouse and retains retry idempotency', () => { storageMock(); resetFulfillmentSession() assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), false) updateFulfillmentDraft('dispatch', { currentNo: 'DSP-001', palletCode: 'PLT-001', packages: 3 }) const first = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 }) const retry = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 }) assert.equal(retry.value, first.value) fulfillment.dispatch.currentNo = '' assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true) assert.equal(fulfillment.dispatch.currentNo, 'DSP-001') assert.equal(fulfillment.dispatch.pendingKeys[first.signature], first.value) releaseFulfillmentKey('dispatch', first.signature) const next = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 }) assert.notEqual(next.value, first.value) assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false) assert.equal(fulfillment.dispatch.currentNo, '') }) test('pages expose loading empty error offline and permission diagnostics with guarded writes', async () => { const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')]) for (const page of [dispatch, outbound]) { assert.match(page, /startScanBroadcast/) assert.match(page, /正在加载/) assert.match(page, /暂无/) assert.match(page, /requestId[\s\S]*traceId/) assert.match(page, /error\.network[\s\S]*幂等键/) assert.match(page, /A0403[\s\S]*没有海外仓 PDA 作业权限/) assert.match(page, /fulfillmentIdempotencyKey/) assert.match(page, /releaseFulfillmentKey/) assert.match(page, /if \(!this\.can/) assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/) } assert.match(dispatch, /completeDispatchPicking/) assert.match(dispatch, /this\.remainingPackages === 0/) assert.doesNotMatch(dispatch, /partialReason|reason \}/) assert.match(dispatch, /this\.pickingCompleted && this\.allPicked && !this\.departed/) assert.match(dispatch, /this\.selectedPosition\?\.availablePackages \|\| 0/) assert.doesNotMatch(dispatch, /availablePackages - (?:this\.)?selectedPosition.*pickedPackages/) assert.match(dispatch, /departDispatch/) assert.doesNotMatch(dispatch, /departedNo/) assert.match(outbound, /completeOutboundTask/) assert.match(outbound, /this\.remainingPackages === 0/) assert.match(outbound, /position\.availablePackages <= 0/) assert.doesNotMatch(outbound, /position\.availablePackages - position\.pickedPackages/) for (const page of [dispatch, outbound]) assert.match(page, /await get(?:Dispatch|Outbound)Task[\s\S]*releaseFulfillmentKey/) assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/) })