204 lines
13 KiB
JavaScript
204 lines
13 KiB
JavaScript
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('carton relabel uses one scan input and shows the confirmed FBA mapping after saving', async () => {
|
|
const page = await read('../src/pages/relabel/relabel.vue')
|
|
assert.equal((page.match(/name="quickCarton"/g) || []).length, 1)
|
|
assert.match(page, /name="quickSku"/)
|
|
assert.match(page, /v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining\(selectedItem\) > 0"/)
|
|
assert.match(page, /selectItem\(item\) \{ if \(this\.taskTab === 'carton'\) \{ this\.activeTarget = 'quick-carton'; return \}/)
|
|
const start = page.indexOf('async submitCarton(raw)')
|
|
const scan = page.slice(start, page.indexOf('removePhoto(index)', start))
|
|
assert.ok(scan.indexOf('await scanRelabelCarton') < scan.indexOf('this.applyCurrent(result)'))
|
|
assert.ok(scan.indexOf('this.applyCurrent(result)') < scan.indexOf('uni.showModal'))
|
|
assert.match(scan, /showCancel: false/)
|
|
assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
|
|
})
|
|
|
|
test('mixed relabel keeps SKU work open and return bulk also mounts pallet work', async () => {
|
|
const page = await read('../src/pages/relabel/relabel.vue')
|
|
const body = page.match(/remaining\(item\) \{([^\n]+)\},/)[1]
|
|
const remaining = new Function('item', body)
|
|
const mixed = { operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 1,
|
|
cartonProcessedPackages: 1, skuProcessedPackages: 0, skippedPackages: 0, exceptionPackages: 0 }
|
|
assert.equal(remaining.call({ taskTab: 'sku' }, mixed), 1)
|
|
assert.equal(remaining.call({ taskTab: 'carton' }, mixed), 0)
|
|
assert.match(page, /<RelabelPalletWork v-if="taskTab === 'carton' \|\| isReturnBulk"/)
|
|
})
|
|
|
|
test('relabel list confirms before starting a new task and completion returns to the list', async () => {
|
|
const page = await read('../src/pages/relabel/relabel.vue')
|
|
assert.doesNotMatch(page, /name="relabelTask"/)
|
|
assert.match(page, /@tap="openExecution\(item\)"/)
|
|
assert.match(page, /async openExecution\(item\)/)
|
|
assert.match(page, /const confirmed = await this\.confirmStartTask\(item\)[\s\S]*if \(!confirmed\) return[\s\S]*claimRelabelTask/)
|
|
assert.match(page, /relabelExecute&task=\$\{encodeURIComponent\(item\.relabelNo\)\}/)
|
|
assert.match(page, /当前账号正在执行 \$\{active\.relabelNo\}/)
|
|
assert.match(page, /v-if="showCurrent" class="detail-toolbar"[\s\S]*@tap="closeCurrent">\u2039 返回列表<\/button>/)
|
|
assert.match(page, /closeCurrent\(\) \{ this\.current = null;[\s\S]*this\.taskCode = '' \}/)
|
|
assert.match(page, /updateWarehouseWork\('relabel', \{ currentNo: '', photoFileIds: \[\], photoNames: \[\], remark: '' \}\)/)
|
|
assert.match(page, /if \(completed\) this\.goToClaimList\(\)/)
|
|
assert.match(page, /goToClaimList\(\) \{ this\.taskCode = ''; uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelReceive' \}\) \}/)
|
|
})
|
|
|
|
test('relabel review opens a pending task as customer-confirmation read-only detail', async () => {
|
|
const page = await read('../src/pages/relabel/relabel.vue')
|
|
const body = page.match(/showCurrent\(\) \{([^\n]+)\},/)[1]
|
|
const visible = new Function(body)
|
|
const context = { stage: 'execute', entryTaskNo: '', current: { relabelNo: 'RL-1', taskStatus: 'PROCESSING' }, candidates: [{ relabelNo: 'RL-1', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
|
|
assert.equal(visible.call(context), true, 'detail without ownership field must use server candidate ownership')
|
|
assert.equal(visible.call({ ...context, candidates: [] }), false)
|
|
assert.equal(visible.call({ ...context, stage: 'review', current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), true)
|
|
assert.equal(visible.call({ ...context, current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), false)
|
|
assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据'/)
|
|
assert.match(page, /<view v-else class="task-list">[\s\S]*@tap="openReview\(item\)"/)
|
|
assert.match(page, /查看并复核/)
|
|
assert.match(page, /async openReview\(item\)/)
|
|
assert.match(page, /现场换标已经完成。请客户在客户工作台核对完工结果并确认/)
|
|
assert.doesNotMatch(page, /@tap="review\('APPROVE'\)"/)
|
|
assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="bootstrap"/)
|
|
assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/)
|
|
const bootstrapBody = page.match(/async bootstrap\(\) \{([^\n]+)\},/)[1]
|
|
const bootstrap = new (Object.getPrototypeOf(async function () {}).constructor)(bootstrapBody)
|
|
const opened = []
|
|
await bootstrap.call({ ...context, entryTaskNo: 'RL-1', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
|
|
assert.deepEqual(opened, ['RL-1'])
|
|
await bootstrap.call({ ...context, entryTaskNo: '', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
|
|
assert.deepEqual(opened, ['RL-1'], 'workbench execution entry must stay on the processing-task list')
|
|
})
|
|
|
|
test('returned relabel tasks are highlighted, sorted first, and still obey the single active task lock', async () => {
|
|
const page = await read('../src/pages/relabel/relabel.vue')
|
|
assert.match(page, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做[\s\S]*优先处理/)
|
|
assert.match(page, /退回备注:\{\{ item\.reviewRemark \}\}/)
|
|
assert.match(page, /\.sort\(\(left, right\) => Number\(right\.reviewDecision === 'REWORK'\) - Number\(left\.reviewDecision === 'REWORK'\)/)
|
|
assert.match(page, /const active = this\.candidates\.find\(\(candidate\) => candidate\.taskStatus === 'PROCESSING' && candidate\.claimedByCurrentOperator === true\)/)
|
|
assert.match(page, /if \(active && active\.relabelNo !== item\.relabelNo\) return this\.promptActiveTask\(active\)/)
|
|
assert.match(page, /AVAILABLE: '待认领'/)
|
|
})
|
|
|
|
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/)
|
|
})
|