145 lines
8.8 KiB
JavaScript
145 lines
8.8 KiB
JavaScript
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/)
|
|
})
|