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, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload } from '../src/api/fulfillment.js' import { session } from '../src/stores/session.js' import { toDispatchError } from '../src/utils/dispatch-error.js' const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') test('outbound partial pick submits the selected carton and distinguishes shared-pallet items', async () => { const first = outboundPickPayload({ outboundPalletItemId: '11' }, { inventoryPositionId: '21', cartonMark: 'BOX-A' }, '1') assert.deepEqual(first, { outboundPalletItemId: '11', inventoryPositionId: '21', packages: 1, cartonMarks: ['BOX-A'] }) assert.notDeepEqual(first, outboundPickPayload({ outboundPalletItemId: '12' }, { inventoryPositionId: '22', cartonMark: 'BOX-B' }, 1)) assert.deepEqual(outboundPickPayload({ outboundPalletItemId: '13' }, { inventoryPositionId: '23' }, 2).cartonMarks, []) const page = await read('../src/pages/outbound/outbound.vue') assert.match(page, /:key="item.outboundPalletItemId"/) assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages\)/) assert.match(page, /pickOutboundTask\(this.current.taskNo, \{ \.\.\.payload, idempotencyKey: key.value \}\)/) assert.match(page, /pickOutboundPallet\(this.current.taskNo, code, \{ idempotencyKey: key.value \}\)/) assert.match(page, /item\.palletNo === code/) }) 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/resolve/')) data = { workflow: 'DISPATCH', dispatchNo: 'DSP-1', outboundNo: 'OUT-1' } 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 resolveOutboundTask('OUT/1') await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' }) await pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' }) await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' }) assert.equal(calls.length, 11) 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.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pallets/DP%2F1/pick'))) assert.ok(calls.some((call) => call.url.endsWith('/outbound-tasks/resolve/OUT%2F1'))) 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', canDepart: false, departBlockReason: '请先在平台完成“转派送”', 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) assert.equal(dispatch.canDepart, false) assert.equal(dispatch.departBlockReason, '请先在平台完成“转派送”') 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, /fulfillmentIdempotencyKey/) assert.match(page, /releaseFulfillmentKey/) assert.match(page, /if \(!this\.can/) assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/) } assert.match(dispatch, /completeDispatchPicking/) assert.match(dispatch, /toDispatchError/) assert.match(dispatch, /this\.remainingPackages === 0/) assert.doesNotMatch(dispatch, /partialReason|reason \}/) assert.match(dispatch, /this\.pickingCompleted && this\.allPicked && this\.current\?\.canDepart === true && !this\.departed/) assert.match(dispatch, /current\.departBlockReason/) 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, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/) assert.match(outbound, /一件代发扫描托盘码可整托出库/) assert.match(outbound, /扫描 DP 托盘码,将一次拣取该托全部未拣箱件/) assert.match(outbound, /if \(!this\.current\) return this\.openScannedTask\(code\)/) assert.doesNotMatch(outbound, /if \(!this\.current\)[^\n]*openTask\(code\)/) 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/) }) test('fulfillment workbench entries open task lists before details and keep explicit cross-workflow resume', async () => { const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')]) assert.match(dispatch, /resumeOnEntry: false/) assert.match(dispatch, /onLoad\(options\) \{ this\.resumeOnEntry = options\?\.resume === '1' \}/) assert.match(dispatch, /if \(this\.resumeOnEntry && fulfillment\.dispatch\.currentNo\) this\.openTask/) assert.match(outbound, /if \(!this\.bootstrapped\) \{ this\.bootstrapped = true; this\.scanCode = ''; this\.loadTasks\(\); return \}/) assert.match(outbound, /pages\/dispatch\/dispatch\?resume=1/) for (const page of [dispatch, outbound]) { assert.match(page, /detail-toolbar/) assert.match(page, /上次作业/) assert.match(page, /返回列表/) } }) test('dispatch vehicle-plate conflict uses actionable Chinese copy and preserves diagnostics', () => { const error = toDispatchError({ code: 'A0409', message: 'Vehicle plate is required before PDA push or picking', requestId: 'faccb2ee-5e05-4904-9bef-bc983638b47b', traceId: '43ca73abd84fab3e2992aa50e48fb48f' }, '提交排车拣货失败') assert.deepEqual(error, { message: '请先在平台维护车牌信息后再拣货', code: 'A0409', requestId: 'faccb2ee-5e05-4904-9bef-bc983638b47b', traceId: '43ca73abd84fab3e2992aa50e48fb48f', network: false }) const unknown = toDispatchError({ code: 'A0409', message: 'Another dispatch conflict', requestId: 'request-2' }, '失败') assert.equal(unknown.message, 'Another dispatch conflict') assert.equal(unknown.requestId, 'request-2') assert.equal(toDispatchError({ code: 'A0403', message: 'Forbidden' }).message, '当前账号没有海外仓 PDA 作业权限,请联系管理员授权') }) test('receiving conflict recovery keeps request and trace evidence visible on narrow screens', async () => { const operation = await read('../src/pages/operation/operation.vue') assert.match(operation, /openContainer\(candidate\)[\s\S]*recoverCurrentAfterConflict\(error, true\)/) assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/) })