feat: complete overseas PDA warehouse operations
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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\//)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user