feat: add phase 5 overseas PDA fulfillment

This commit is contained in:
2026-09-01 14:23:55 +08:00
parent 49aad23098
commit fc8a52bc4b
15 changed files with 712 additions and 7 deletions
+1
View File
@@ -10,6 +10,7 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
const paths = [
'../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
'../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue',
'../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue',
'../src/pages/history/history.vue', '../src/pages/scanner-test/scanner-test.vue',
'../src/components/PortalHeader.vue', '../src/components/PopupHeader.vue',
'../src/components/ReceivingSessionSummary.vue', '../src/components/ScanTargetInput.vue'
+144
View File
@@ -0,0 +1,144 @@
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/)
})
+3 -3
View File
@@ -24,12 +24,12 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity'
assert.doesNotMatch(styles, /home-operation-primary[^}]*grid-column:\s*1\s*\/\s*-1/)
})
test('overseas workbench contains only confirmed inbound and in-warehouse domains', async () => {
test('overseas workbench contains confirmed warehouse and phase-five outbound domains without delivery', async () => {
const [data, session, runtime] = await Promise.all([
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
])
for (const label of ['入仓作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '出库履约']) assert.doesNotMatch(data, new RegExp(excluded))
for (const label of ['入仓作业', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
assert.match(session, /overseas-pda-auth-session/)
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
})
+9 -1
View File
@@ -6,6 +6,7 @@ import {
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
} from '../src/stores/session.js'
import { receiving } from '../src/stores/receiving.js'
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
const future = () => Math.floor(Date.now() / 1000) + 3600
const jwt = (claims) => [
@@ -124,7 +125,7 @@ test('restore rebuilds identity from claims and removes expired or tampered stor
assert.equal(values.has('overseas-pda-auth-session'), false)
})
test('sign out clears tokens, frozen warehouse identity, and every receiving draft', () => {
test('sign out clears tokens, frozen warehouse identity, and every receiving or fulfillment draft', () => {
const values = storage(); signOut()
signInWithTokens(tokenPair())
receiving.candidates = [{ containerNo: 'CONT-1' }]
@@ -134,6 +135,9 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra
receiving.resolvedCarton = { cartonMark: 'BOX-1' }
receiving.resolvedPallet = { palletCode: 'PALLET-1' }
receiving.lastReceipt = { acceptedPackages: 1 }
restoreFulfillmentDraft('42:900')
updateFulfillmentDraft('dispatch', { currentNo: 'DSP-1', palletCode: 'PLT-1' })
updateFulfillmentDraft('outbound', { currentTaskNo: 'PDA-1', scanCode: 'BOX-1' })
signOut()
assert.equal(session.currentUser, null)
@@ -148,4 +152,8 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra
assert.equal(receiving.resolvedCarton, null)
assert.equal(receiving.resolvedPallet, null)
assert.equal(receiving.lastReceipt, null)
assert.equal(fulfillment.scopeKey, '')
assert.equal(fulfillment.dispatch.currentNo, '')
assert.equal(fulfillment.outbound.currentTaskNo, '')
assert.equal(values.has('overseas-pda-fulfillment-draft-v1'), false)
})
+4
View File
@@ -7,9 +7,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' },
{ module: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '托盘管理' }
])
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch')
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
const pendingNames = ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']
for (const name of pendingNames) {