feat: complete overseas PDA warehouse operations

This commit is contained in:
2026-09-04 12:42:17 +08:00
parent e8f5d053ee
commit f960b9fe5f
18 changed files with 678 additions and 66 deletions
+8 -2
View File
@@ -11,6 +11,7 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
'../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/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.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'
@@ -39,13 +40,16 @@ test('operator and warehouse cards never fall back to internal IDs', async () =>
})
test('operation templates expose business references only and keep diagnostic evidence', async () => {
const [operation, pallet, summary, apiFiles] = await Promise.all([
const [operation, pallet, summary, stocktake, move, relabel, query, apiFiles] = await Promise.all([
read('../src/pages/operation/operation.vue'),
read('../src/pages/pallet/pallet.vue'),
read('../src/components/ReceivingSessionSummary.vue'),
read('../src/pages/stocktake/stocktake.vue'), read('../src/pages/move/move.vue'),
read('../src/pages/relabel/relabel.vue'), read('../src/pages/query/query.vue'),
Promise.all([
read('../src/api/inbound.js'), read('../src/api/pallet.js'),
read('../src/api/overseas.js'), read('../src/api/receiving-adapter.js')
read('../src/api/overseas.js'), read('../src/api/receiving-adapter.js'),
read('../src/api/warehouse-operations.js'), read('../src/api/relabel.js')
]).then((values) => values.join('\n'))
])
@@ -59,6 +63,7 @@ test('operation templates expose business references only and keep diagnostic ev
}
for (const field of ['orderId', 'inboundId', 'planId', 'containerBookingId', 'sourceContainerBookingId', 'warehouseId', 'palletId', 'locationId']) {
assert.doesNotMatch(template(pallet), new RegExp(`\\b${field}\\b`))
for (const page of [stocktake, move, relabel, query]) assert.doesNotMatch(template(page), new RegExp(`\\b${field}\\b`))
}
for (const businessField of ['containerNo', 'inboundCode', 'cartonMark', 'palletCode']) {
@@ -66,6 +71,7 @@ test('operation templates expose business references only and keep diagnostic ev
}
assert.match(operation, /requestId[\s\S]*traceId/)
assert.match(pallet, /requestId[\s\S]*traceId/)
for (const page of [stocktake, move, relabel, query]) assert.match(page, /requestId[\s\S]*traceId/)
assert.doesNotMatch(apiFiles, /\/api\/platform\/v1\/|inbound-code-relationships/)
})
+7 -7
View File
@@ -86,18 +86,18 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(overseas, /requestId/)
})
test('remaining unimplemented warehouse operations cannot submit prototype scans', async () => {
const [operation, home, module, workbench] = await Promise.all([
test('warehouse operations use dedicated real pages and contain no prototype task data', async () => {
const [operation, home, module, workbench, pages] = await Promise.all([
read('../src/pages/operation/operation.vue'), read('../src/pages/home/home.vue'),
read('../src/pages/module/module.vue'), read('../src/data/overseas-workbench.js')
read('../src/pages/module/module.vue'), read('../src/data/overseas-workbench.js'), read('../src/pages.json')
])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
assert.match(operation, /功能暂未开放|扫码不会提交/)
assert.match(home, /暂未开放/)
assert.match(module, /灰色功能尚未开放/)
assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype|模拟成功/)
for (const key of ['stocktake', 'move', 'relabelReceive', 'inventoryQuery', 'locationQuery', 'palletQuery']) {
assert.match(workbench, new RegExp(`key: '${key}'[^\\n]*available: false`))
assert.match(workbench, new RegExp(`key: '${key}'[^\\n]*available: true[^\\n]*route:`))
}
for (const page of ['pages/stocktake/stocktake', 'pages/move/move', 'pages/relabel/relabel', 'pages/query/query']) assert.match(pages, new RegExp(page))
assert.match(home + module, /operation\.route \|\|/)
})
test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => {
+127
View File
@@ -0,0 +1,127 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { session } from '../src/stores/session.js'
import {
countStocktake, createStocktake, getStocktake, listStocktakes, moveInventory,
resolveInventory, resolveWarehouseLocation, resolveWarehousePallet, submitStocktake
} from '../src/api/warehouse-operations.js'
import {
claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit,
reportRelabelException, reviewRelabelTask, skipRelabelUnit
} from '../src/api/relabel.js'
import {
releaseWarehouseKey, resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork,
warehouseIdempotencyKey, warehouseWork
} from '../src/stores/warehouse-work.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
function requestMock() {
const calls = []
session.apiBase = '/overseas-api'; session.token = 'warehouse-token'
globalThis.uni = {
request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
let data = {}
if (options.url.includes('/stocktakes?')) data = { records: [], page: 1, size: 100, total: 0, pages: 0 }
else if (options.url.endsWith('/stocktakes')) data = { task: { taskNo: 'CC-1', version: 0 }, items: [] }
else if (/\/stocktakes\/[^/]+$/.test(options.url)) data = { task: { taskNo: 'CC/1', version: 0 }, items: [] }
else if (options.url.includes('/stocktakes/') && options.url.endsWith('/submit')) data = { task: { taskNo: 'CC/1', status: 'SUBMITTED', version: 1 }, items: [] }
else if (options.url.includes('/stocktakes/') && options.url.endsWith('/counts')) data = { positionId: '9', actualPackages: 2 }
else if (options.url.endsWith('/inventory-moves')) data = { moveNo: 'MV-1', palletCode: 'PLT-1', status: 'COMPLETED' }
else if (options.url.endsWith('/inventory/resolve')) data = { queryCode: 'SKU-1', items: [] }
else if (options.url.endsWith('/locations/resolve')) data = { locationCode: 'A-01' }
else if (options.url.endsWith('/pallets/resolve')) data = { palletCode: 'PLT-1', version: 2, inventory: [] }
else if (options.url.endsWith('/relabel-tasks/candidates')) data = []
else data = { relabelNo: 'RL/1', taskStatus: 'PROCESSING', version: 1, items: [] }
options.success({ statusCode: 200, data: { code: '200', data }, header: {} })
}
}
return calls
}
test('warehouse API calls exact PDA paths and never supplies client warehouse scope', async () => {
const calls = requestMock()
await listStocktakes(); await createStocktake({ scopeType: 'LOCATION', scopeCode: 'A-01', idempotencyKey: 'create-1' })
await getStocktake('CC/1'); await countStocktake('CC/1', { positionId: '9', actualPackages: 2, expectedVersion: 0, idempotencyKey: 'count-1' })
await submitStocktake('CC/1', { expectedVersion: 1, reason: 'cycle', idempotencyKey: 'submit-1' })
await moveInventory({ sourcePalletCode: 'PLT-1', targetLocationCode: 'B-01', expectedPalletVersion: 2, reason: 'capacity', idempotencyKey: 'move-1' })
await resolveInventory('SKU-1'); await resolveWarehouseLocation('A-01'); await resolveWarehousePallet('PLT-1')
assert.equal(calls.length, 9)
for (const path of ['/stocktakes?page=1&size=100&status=IN_PROGRESS', '/stocktakes/CC%2F1/counts', '/stocktakes/CC%2F1/submit', '/inventory-moves', '/inventory/resolve', '/locations/resolve', '/pallets/resolve']) {
assert.ok(calls.some((call) => call.url.endsWith(path)), path)
}
assert.ok(calls.every((call) => call.authorization === 'Bearer warehouse-token'))
for (const call of calls) {
assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false)
assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false)
}
})
test('relabel API covers claim, unit outcomes, completion and review with encoded business numbers', async () => {
const calls = requestMock()
await listRelabelCandidates(); await getRelabelTask('RL/1')
await claimRelabelTask('RL/1', { idempotencyKey: 'claim-1' })
await relabelUnit('RL/1', { itemId: '7', oldLabel: 'OLD', newLabel: 'NEW', expectedVersion: 1, idempotencyKey: 'unit-1' })
await skipRelabelUnit('RL/1', { itemId: '7', oldLabel: 'OLD-2', reason: 'skip', expectedVersion: 2, idempotencyKey: 'skip-1' })
await reportRelabelException('RL/1', { itemId: '7', oldLabel: 'OLD-3', exceptionCode: 'DAMAGED', reason: 'damage', expectedVersion: 3, idempotencyKey: 'exception-1' })
await completeRelabelTask('RL/1', { photoFileIds: ['8'], expectedVersion: 4, idempotencyKey: 'complete-1' })
await reviewRelabelTask('RL/1', { decision: 'APPROVE', expectedVersion: 5, idempotencyKey: 'review-1' })
for (const path of ['/relabel-tasks/candidates', '/relabel-tasks/RL%2F1', '/claim', '/units', '/units/skip', '/exceptions', '/complete', '/review']) {
assert.ok(calls.some((call) => call.url.endsWith(path)), path)
}
assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url)))
})
test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => {
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) }
}
resetWarehouseWork()
assert.equal(restoreWarehouseWork('operator-a:warehouse-a'), false)
updateWarehouseWork('move', { sourcePalletCode: 'PLT-1', targetLocationCode: 'A-01', reason: 'capacity' })
const first = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
const retry = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
assert.equal(retry.value, first.value)
warehouseWork.move.sourcePalletCode = ''
assert.equal(restoreWarehouseWork('operator-a:warehouse-a'), true)
assert.equal(warehouseWork.move.sourcePalletCode, 'PLT-1')
releaseWarehouseKey('move', first.signature)
assert.equal(restoreWarehouseWork('operator-b:warehouse-a'), false)
assert.equal(warehouseWork.move.sourcePalletCode, '')
})
test('new operation pages wire scans, manual inputs, conflict refresh, loading and diagnostics', async () => {
const [stocktake, move, relabel, query, workbench, pages, api, relabelApi] = await Promise.all([
read('../src/pages/stocktake/stocktake.vue'), read('../src/pages/move/move.vue'),
read('../src/pages/relabel/relabel.vue'), read('../src/pages/query/query.vue'),
read('../src/data/overseas-workbench.js'), read('../src/pages.json'),
read('../src/api/warehouse-operations.js'), read('../src/api/relabel.js')
])
for (const page of [stocktake, move, relabel, query]) {
assert.match(page, /startScanBroadcast/)
assert.match(page, /ScanTargetInput/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(page, /正在|加载|查询中/)
assert.match(page, /暂无|未找到|无库存|不是处理中/)
assert.doesNotMatch(page, /模拟成功|customerId:|warehouseId:/)
}
for (const page of [stocktake, move, relabel]) {
assert.match(page, /warehouseIdempotencyKey/)
assert.match(page, /releaseWarehouseKey/)
assert.match(page, /isConflict[\s\S]*刷新/)
}
assert.match(stocktake, /expectedVersion: item\.version/)
assert.match(move, /expectedPalletVersion: Number\(this\.source\.version/)
assert.match(relabel, /expectedVersion: this\.current\.version/)
assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype/)
for (const route of ['stocktake/stocktake', 'move/move', 'relabel/relabel', 'query/query']) assert.match(pages, new RegExp(route))
assert.doesNotMatch(api + relabelApi, /\/api\/platform\/|customerId|warehouseId/)
})
+12 -5
View File
@@ -9,16 +9,23 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
{ module: 'inbound', name: '拆柜收货' },
{ module: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '托盘管理' }
{ module: 'inventory', name: '库存盘点' },
{ module: 'inventory', name: '商品移库' },
{ module: 'inventory', name: '托盘管理' },
{ module: 'relabel', name: '换标收货' },
{ module: 'relabel', name: '换标执行' },
{ module: 'relabel', name: '换标复核' },
{ module: 'query', name: '库存查询' },
{ module: 'query', name: '库位查询' },
{ module: 'query', 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) {
for (const name of ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
const operation = operations.find((item) => item.name === name)
assert.equal(operation?.available, false, `${name} must fail closed until its PDA API exists`)
assert.equal(operation?.badge, undefined)
assert.equal(operation?.available, true)
assert.match(operation?.route || '', /^\/pages\//)
}
})