Merge origin/release/test and preserve storage rent checks with request unlock

This commit is contained in:
2026-10-26 13:51:38 +08:00
52 changed files with 761 additions and 761 deletions
+1 -2
View File
@@ -8,7 +8,7 @@ const interpolations = (source) => [...template(source).matchAll(/\{\{([\s\S]*?)
test('all registered user-facing pages avoid rendering internal identifiers', async () => {
const paths = [
'../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
'../src/pages/login/login.vue', '../src/pages/home/home.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',
@@ -86,7 +86,6 @@ test('missing user-visible relationships use explicit empty labels instead of ID
read('../src/components/ReceivingSessionSummary.vue'),
read('../src/pages/pallet/pallet.vue')
])
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
assert.match(summary, /unique\.length \? unique : \['—'\]/)
assert.match(pallet, /inboundCode \|\| '无入仓码'/)
assert.match(pallet, /locationCode \|\| '未绑定库位'/)
+46 -33
View File
@@ -18,6 +18,18 @@ function component(overrides = {}) {
for (const [key, getter] of Object.entries(definition.computed)) Object.defineProperty(state, key, { get: () => getter.call(state) })
return { state, events, values, definition }
}
test('five existing slots carry their business names into upload, replace and preview controls', async () => {
const { state } = component()
assert.deepEqual(state.slots, ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'])
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' })
const template = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).split('<script>')[0]
assert.match(template, /class="photo-label">\{\{ slotLabels\[slot\] \}\}/)
assert.match(template, /:aria-label="`\$\{snapshot\?\.photos.some\(item => item.slot === slot\) \? '更换' : '上传'\}\$\{slotLabels\[slot\]\}`"/)
assert.match(template, /:aria-label="`预览\$\{slotLabels\[slot\]\}`"/)
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
})
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
@@ -34,44 +46,45 @@ test('PDA upload preserves the original request key through the authenticated up
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/)
assert.equal(calls[0].filePath, 'local-image')
})
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
assert.throws(() => normalizeLoadingDocuments(data({ photos: [photo, photo] }), 'PC/1'), /重复/)
assert.throws(() => normalizeLoadingDocuments(data({ recordVersion: null }), 'PC/1'), /不一致/)
})
test('unknown binding keeps the same file, version and key and does not reupload', async () => {
let uploads = 0; const bindings = []
const { state, events } = component({ uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async (_no, _slot, body) => { bindings.push(structuredClone(body)); if (bindings.length === 1) throw { network: true, message: 'unknown' }; return data({ stage: 'DELIVERING', complete: true, recordVersion: '5' }) } })
await state.load(); await state.choose('LOADING_SCENE')
assert.ok(state.pending); assert.equal(events.some(event => event[0] === 'saved'), false)
await state.recover()
assert.equal(uploads, 1); assert.deepEqual(bindings[0], bindings[1]); assert.equal(state.pending, null)
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('a failed upload does not replace a saved photo or advance the stage', async () => {
const { state } = component({ getLoadingDocuments: async () => data({ photos: [photo] }), uploadLoadingPhoto: async () => { throw { network: true, message: 'unknown' } } })
await state.load(); await state.choose('LOADING_SCENE')
assert.equal(state.snapshot.photos[0].fileId, '1001'); assert.equal(state.snapshot.stage, 'AFTER_LOADING')
assert.ok(state.pending.key); assert.equal(state.pending.photo, undefined)
})
test('definitive file rejection allows selecting a new image while retaining the saved one', async () => {
let uploads = 0
const { state } = component({ getLoadingDocuments: async () => data({ photos: [photo] }), uploadLoadingPhoto: async () => {
if (++uploads === 1) throw { code: 'A0400', message: '图片内容无效' }
return photo
} })
await state.load(); await state.choose('LOADING_SCENE')
assert.equal(state.pending, null); assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
assert.equal(state.error.message, '图片内容无效')
await state.choose('LOADING_SCENE'); assert.equal(uploads, 2); assert.equal(state.pending, null)
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0422', message: 'invalid' }]) test('upload failure leaves saved photos and unlocks current selection: ' + failure.message, async () => {
const uploads = []; let reads = 0, choice = 0
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ photos: [photo] }) }, uploadLoadingPhoto: async (...args) => { uploads.push(args); throw failure } })
uni.chooseImage = async () => ({ tempFilePaths: ['image-' + ++choice] })
await state.load(); await state.choose(photo.slot)
assert.equal(state.busy, false); assert.equal(values.size, 0); assert.equal(reads, 1)
assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
await state.choose(photo.slot)
assert.equal(uploads.length, 2); assert.equal(uploads[1][2], 'image-2'); assert.notEqual(uploads[0][3], uploads[1][3])
assert.deepEqual(events.at(-1), ['busy-change', false])
})
test('loading documents can be saved during LOADING without claiming departure', async () => {
assert.equal(normalizeLoadingDocuments(data({ stage: 'LOADING' }), 'PC/1').stage, 'LOADING')
assert.throws(() => normalizeLoadingDocuments(data({ stage: 'BEFORE_LOADING' }), 'PC/1'), /不一致/)
const { state, events } = component({ getLoadingDocuments: async () => data({ stage: 'LOADING' }), bindLoadingPhoto: async () => data({ stage: 'LOADING', recordVersion: '1', photos: [photo] }) })
await state.load(); await state.choose('LOADING_SCENE')
assert.equal(state.snapshot.stage, 'LOADING'); assert.equal(state.pending, null)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
test('binding failure releases immediately and the next selection uses a new file, key and current version', async () => {
const binds = []; let uploads = 0, version = '0', reads = 0
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ recordVersion: version }) }, uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++uploads) }), bindLoadingPhoto: async (_no, _slot, body) => { binds.push(body); if (binds.length === 1) throw { network: true }; return data({ photos: [photo] }) } })
await state.load(); await state.choose(photo.slot)
assert.equal(state.busy, false); assert.equal(reads, 2); assert.equal(values.size, 0)
version = '7'; await state.choose(photo.slot)
assert.equal(uploads, 2); assert.equal(binds[1].recordVersion, '7'); assert.notEqual(binds[1].fileId, binds[0].fileId)
assert.notEqual(binds[1].idempotencyKey, binds[0].idempotencyKey); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('manual refresh after a lost binding response reflects server success without another write', async () => {
let binds = 0, latest = data()
const { state } = component({ getLoadingDocuments: async () => latest, bindLoadingPhoto: async () => { binds++; latest = data({ editable: false, complete: true, stage: 'DELIVERING', photos: [photo] }); throw { network: true } } })
await state.load(); await state.choose(photo.slot); assert.equal(state.busy, false)
await state.load(); assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(binds, 1); assert.equal(state.editable, false)
})
test('in-flight upload blocks duplicate clicks and unmount releases the parent busy flag', async () => {
let resolveUpload, uploads = 0
const { state, events, definition } = component({ uploadLoadingPhoto: () => { uploads++; return new Promise(resolve => { resolveUpload = resolve }) } })
await state.load(); const first = state.choose(photo.slot)
while (!resolveUpload) await new Promise(resolve => setImmediate(resolve))
await state.choose(photo.slot); assert.equal(uploads, 1)
definition.beforeUnmount.call(state); assert.deepEqual(events.at(-1), ['busy-change', false])
resolveUpload(photo); await first; assert.equal(events.some(event => event[0] === 'saved'), false)
})
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { parse, compileTemplate } from 'vue/compiler-sfc'
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
const photos = await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')
for (const [filename, source] of [['dispatch.vue', page], ['DispatchLoadingDocuments.vue', photos]]) {
test(`${filename} compiles its actual presentation template`, () => {
const { descriptor, errors } = parse(source, { filename })
assert.deepEqual(errors, [])
const compiled = compileTemplate({ source: descriptor.template.content, filename, id: 'dispatch-presentation' })
assert.deepEqual(compiled.errors, [])
})
}
test('picking separates source facts, current quantity and completion while retaining scanner and action guards', () => {
const { descriptor } = parse(page)
const template = descriptor.template.content
for (const title of ['扫码拣货', '任务摘要', '拣货进度', '托盘与货物来源', '当前拣货', '确认装车完成']) assert.ok(template.includes(title), title)
assert.match(template, /class="picking-fact-label">客户<\/text>/)
assert.match(template, /class="picking-fact-label">目的地<\/text>/)
assert.match(template, /aria-label="本次拣货箱数"[^>]+:disabled="busy"[^>]+@input="updatePackages"/)
assert.match(template, /name="dispatch"[^>]+:active="activeTab === 'picking'"[^>]+@submit="handleScan"/)
assert.match(template, /:disabled="!canPick \|\| busy" @tap="submitPick"/)
assert.match(template, /:disabled="!canComplete \|\| busy" @tap="prepareLoading"/)
assert.match(template, /@tap="selectPallet\(item\)"/)
assert.match(page, /\.picking-fact > text:last-child \{[^}]*overflow-wrap: anywhere/)
})
+76 -105
View File
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 0, pickedPackages: 0 }], returnCartons: [], ...overrides })
@@ -23,23 +23,26 @@ test('invalid, missing and unsafe native quantities fail at every item, position
})
const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', pickingReconciled: false, actions: { PICK: { allowed: true }, LOADING_COMPLETE: { allowed: true } }, items: [item()], ...overrides })
function storage() { const values = new Map(); globalThis.uni = { setStorageSync: (key, value) => values.set(key, structuredClone(value)), getStorageSync: key => structuredClone(values.get(key)), removeStorageSync: key => values.delete(key), showModal: async () => ({ confirm: true }) }; resetFulfillmentSession(); restoreFulfillmentDraft('operator:warehouse'); return values }
let lastCommand
function page(dependencies = {}) {
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
for (const [api, action] of [['pickDispatchItem', 'PICK'], ['completeDispatchLoading', 'LOADING_COMPLETE'], ['reviewDispatchLoading', 'LOADING_REVIEW']]) {
const original = deps[api]; if (!original) continue
deps[api] = (...args) => { lastCommand = { dispatchNo: args[0], action, body: args.at(-1) }; return original(...args) }
}
// Only bind dependencies actually imported by the page; injecting every mock
// would hide missing production imports behind successful workflow tests.
const importedNames = new Set([...source.matchAll(/^\s*import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+/gm)]
.flatMap(match => (match[1] || match[2]).split(',').map(name => name.trim().split(/\s+as\s+/).at(-1))))
const bindings = Object.entries(deps).filter(([name]) => importedNames.has(name))
const definition = new Function(...bindings.map(([name]) => name), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...bindings.map(([, value]) => value))
const state = { ...definition.data(), current: task() }
for (const [name, method] of Object.entries(definition.methods)) state[name] = method.bind(state)
for (const [name, getter] of Object.entries(definition.computed)) Object.defineProperty(state, name, { get: () => getter.call(state) })
return state
}
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
test('full pending command survives restoration, rejects another operation, and isolates the next operator', () => {
storage(); const pending = beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
const original = structuredClone(JSON.parse(JSON.stringify(pending)))
fulfillment.dispatch.pendingCommand = null; restoreFulfillmentDraft('operator:warehouse')
assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch.pendingCommand)), original)
assert.throws(() => beginDispatchCommand('PC/2', 'LOADING_COMPLETE', { recordVersion: 2 }), /原排车/)
restoreFulfillmentDraft('another:warehouse'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
@@ -50,35 +53,16 @@ test('real picking requires the native gate, physical source and current stage;
})
test('return carton pick keeps its exact item, quantity and explicit version zero without inventing a position', async () => {
storage(); let sent
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
state.handleScan('RC-1'); await state.submitPick()
assert.equal(sent[0], 'PC/1'); assert.equal(sent[1], 'item-1')
assert.deepEqual({ ...sent[2], idempotencyKey: 'key' }, { recordVersion: 0, positionId: null, packages: 1, productQuantity: 5, cartonMark: 'RC-1', remark: null, idempotencyKey: 'key' })
assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('unknown POST queries the original key and retries the unchanged payload; a subsequent equal physical pick gets a new key', async () => {
storage(); const sent = []; let fail = true
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push({ no, id, body }); if (fail) { fail = false; throw { network: true, message: 'offline' } } return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: 'HTTP_404' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.selectPallet(state.current.items[0]); await state.submitPick(); assert.ok(fulfillment.dispatch.pendingCommand)
state.current.recordVersion = 80; await state.recoverCommand(); assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].body.recordVersion, 0)
state.selectPallet(state.current.items[0]); await state.submitPick(); assert.notEqual(sent[2].body.idempotencyKey, sent[1].body.idempotencyKey)
})
test('proven loading completion already delivered to the driver survives a failed GET without another POST', async () => {
storage(); let posts = 0, reads = 0
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
const state = page({ completeDispatchLoading: async () => { posts++; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'AFTER_LOADING', recordVersion: 3 }) } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'AFTER_LOADING')
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('later rejection cannot erase an earlier unknown write and older details cannot release a proven result', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0 }); patchDispatchCommand({ attempted: true })
const state = page({ getDispatchCommand: async () => { throw { code: '404' } }, completeDispatchLoading: async () => { throw { code: 'A0409', message: 'conflict' } } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
state.discardRejected(); assert.ok(fulfillment.dispatch.pendingCommand)
patchDispatchCommand({ result: proof(fulfillment.dispatch.pendingCommand, '9007199254740993') })
const stale = page({ getDispatchTask: async () => task({ recordVersion: '9007199254740992' }) }); await stale.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(stale.error.message, /版本早于/)
assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('actual loading confirmation leaves reference measurements blank and blocks missing unloading mapping', async () => {
storage(); const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: 'pallet-1', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: null, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: false, sourceType: 'ORDER_SNAPSHOT_ALLOCATION' }] }] }
let posts = 0; const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async () => { posts++ } })
@@ -88,14 +72,10 @@ test('actual loading confirmation leaves reference measurements blank and blocks
})
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
state.loadingPreview = { dispatchNo: 'PC/1', recordVersion: undefined, manifestHash: 'manifest' }; state.loadingRows = [{ warehousePalletId: '21', weightKg: 1, volumeCbm: 1, palletSpaces: 1, deliveryIndex: 1 }]; await state.completeLoading(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('a first server error remains unknown and cannot be discarded as a definite business rejection', async () => {
storage(); beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
const state = page({ pickDispatchItem: async () => { throw { code: 'A0500', message: 'server failure' } } })
await state.recoverCommand(); state.discardRejected(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
state.loadingPreview = { dispatchNo: 'PC/1', recordVersion: undefined, manifestHash: 'manifest' }; state.loadingRows = [{ warehousePalletId: '21', weightKg: 1, volumeCbm: 1, palletSpaces: 1, deliveryIndex: 1 }]; await state.completeLoading(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('position picking uses available stock and this source remaining instead of warehouse allocation or picked buckets', () => {
storage(); const state = page(); state.current = task({ items: [item({ scheduledPackages: '3', pickedPackages: '1', positions: [{ positionId: 'empty-position', availablePackages: '0', allocatedPackages: '20', pickedPackages: '0' }, { positionId: 'actual-position', availablePackages: '20', allocatedPackages: '0', pickedPackages: '7' }] })] })
state.selectPallet(state.current.items[0]); assert.equal(state.selectedPosition.positionId, 'actual-position'); assert.equal(state.selectedRemaining, 2)
@@ -115,12 +95,12 @@ for (const scenario of [
state.selectPallet(state.current.items[0]); state.updatePackages({ detail: { value: scenario.packages } })
assert.equal(state.selectedPosition?.positionId ?? null, scenario.position); assert.equal(state.selectedRemaining, scenario.maximum)
assert.equal(state.pickablePackages(state.current.items[0]), scenario.maximum); assert.equal(state.canPick, false)
await state.submitPick(); assert.equal(posts, 0); assert.equal(fulfillment.dispatch.pendingCommand, null)
await state.submitPick(); assert.equal(posts, 0); assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('a shared pallet exposes both relabel sources and picking the second preserves its exact position and quantity', async () => {
storage(); const rows = [item({ scheduledPackages: 3 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', customerName: '客户乙', scheduledPackages: 5, positions: [{ positionId: 'position-2', availablePackages: 5, allocatedPackages: 0, pickedPackages: 0 }] })]
let sent
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows.map((row, index) => index ? { ...row, pickedPackages: 5 } : row) }) })
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows.map((row, index) => index ? { ...row, pickedPackages: 5 } : row) }) })
state.current = task({ items: rows }); state.handleScan('PALLET-1')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.deepEqual(state.sourceChoices.map(row => [row.sourceBusinessNo, row.customerId, state.pickablePackages(row)]), [['RL-1', 'customer-1', 2], ['RL-2', 'customer-2', 5]])
state.chooseSource(state.sourceChoices[1]); state.updatePackages({ detail: { value: '5' } }); await state.submitPick()
@@ -129,7 +109,7 @@ test('a shared pallet exposes both relabel sources and picking the second preser
test('an ambiguous source carton requires explicit source selection and never picks the first matching item', async () => {
storage(); let sent
const rows = [item({ returnCartons: [{ returnCartonId: 'carton-1', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 2 }] }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', returnCartons: [{ returnCartonId: 'carton-2', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 7 }] })]
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows }) })
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows }) })
state.current = task({ items: rows }); state.handleScan('SHARED-MARK')
assert.equal(state.sourceChoices.length, 2); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
state.chooseSource(state.sourceChoices[1]); await state.submitPick()
@@ -144,7 +124,7 @@ for (const sourceType of ['INBOUND_PLAN', 'RETURN_RECEIPT']) test(`ordinary ${so
const raw = JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', taskNo: 'TASK-1', recordVersion: '0', status: 'ACTIVE', stage: 'LOADING', actions: { PICK: { allowed: true } }, pickingReconciled: false, items: [item({ sourceKey: `${sourceType}:source-1`, sourceBusinessType: null, sourceBusinessNo: null, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: '9007199254740993', balanceId: '9007199254740994', availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] })] }))
const normalized = normalizeDispatchTask(raw)
assert.equal(normalized.items[0].sourceBusinessType, null); assert.equal(normalized.items[0].sourceBusinessNo, null)
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => normalizeDispatchTask({ ...raw, recordVersion: 1 }) })
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => normalizeDispatchTask({ ...raw, recordVersion: 1 }) })
state.current = normalized; state.handleScan('PALLET-1'); assert.equal(state.canPick, true); assert.equal(state.selectedRemaining, 1)
assert.equal(state.pickablePackages(state.current.items[0]), 1)
state.updatePackages({ detail: { value: '1' } }); await state.submitPick()
@@ -156,7 +136,7 @@ test('shared relabel pallet with real string 1/0/0 positions requires a source c
let rows = ['1', '2'].map(number => item({ dispatchItemId: `item-${number}`, sourceKey: `source-${number}`, sourceBusinessNo: `RL-${number}`, customerId: `customer-${number}`, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: `position-${number}`, availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] }))
const state = page({ pickDispatchItem: async (no, itemId, body) => {
sent.push({ no, itemId, body }); rows = rows.map(row => row.dispatchItemId === itemId ? { ...row, pickedPackages: '1', positions: row.positions.map(position => ({ ...position, availablePackages: '0' })) } : row)
return proof(fulfillment.dispatch.pendingCommand, ++version)
return proof(lastCommand, ++version)
}, getDispatchTask: async () => task({ recordVersion: version, items: rows, pickingReconciled: rows.every(row => row.pickedPackages === '1') }) })
state.current = task({ recordVersion: version, items: rows }); state.handleScan('PALLET-1')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
@@ -168,7 +148,7 @@ test('shared relabel pallet with real string 1/0/0 positions requires a source c
assert.deepEqual(sent.map(entry => [entry.no, entry.itemId, entry.body.positionId, entry.body.packages, entry.body.recordVersion]), [['PC/1', 'item-2', 'position-2', 1, 5], ['PC/1', 'item-1', 'position-1', 1, 6]])
assert.notEqual(sent[0].body.idempotencyKey, sent[1].body.idempotencyKey)
assert.ok(sent.every(entry => !('customerId' in entry.body) && !('warehouseId' in entry.body)))
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canPick, false); assert.equal(state.canComplete, true)
assert.equal('pendingCommand' in fulfillment.dispatch, false); assert.equal(state.canPick, false); assert.equal(state.canComplete, true)
})
test('RELABEL_ORDER still requires its true business number and all sources require sourceKey', () => {
storage(); const state = page()
@@ -184,7 +164,7 @@ test('two picked logical sources use the single authoritative physical loading r
storage(); const rows = [item({ pickedPackages: 2 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', pickedPackages: 2 })]
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'one-physical-pallet', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: 1, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: true }, { field: 'volumeCbm', actual: true }] }] }
let sent
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.equal('measurements' in sent[1], false)
})
@@ -192,7 +172,7 @@ test('two picked logical sources use the single authoritative physical loading r
const reviewTask = (overrides = {}) => task({ stage: 'BEFORE_LOADING', actions: { LOADING_REVIEW: { allowed: true }, PICK: { allowed: false } }, resources: { vehicle: { plate: 'ABC123', carrierName: '车队' }, driver: { name: '司机', phone: '123' } }, reviews: { dispatchReview: { status: 'APPROVED', reason: '已核对预约' }, loadingReview: { status: 'PENDING' } }, ...overrides })
test('missing appointment blocks approval while preserving rejection to customer service', async () => {
storage(); const sent = []
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
assert.equal(state.reviewTime(null), '未填写')
@@ -210,52 +190,31 @@ test('review task is reachable in the existing list and blocks actual source sel
})
test('reject requires a nonblank reason and approval uses original zero version then refreshes real pick eligibility', async () => {
storage(); let sent
const state = page({ reviewDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
const state = page({ reviewDispatchLoading: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); state.reviewReason = ' '; await state.submitReview('REJECTED')
assert.match(state.error.message, /原因/); assert.equal(fulfillment.dispatch.pendingCommand, null)
assert.match(state.error.message, /原因/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
state.reviewReason = ' 已核对车辆和货物 '; await state.submitReview('APPROVED')
assert.deepEqual({ ...sent[1], idempotencyKey: 'key' }, { recordVersion: 0, decision: 'APPROVED', reason: '已核对车辆和货物', idempotencyKey: 'key' })
assert.equal(state.current.stage, 'LOADING'); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
assert.equal(state.reviewReason, ''); assert.equal(fulfillment.dispatch.pendingCommand, null)
assert.equal(state.reviewReason, ''); assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('rejected loading review returns to customer service with its reason and never enables picking', async () => {
storage(); let sent
const rejected = reviewTask({ recordVersion: 1, actions: { LOADING_REVIEW: { allowed: false } }, reviews: { dispatchReview: { status: 'NOT_SUBMITTED', invalidatedReason: '理货审核驳回,请客服修改后重新提交调度审核' }, loadingReview: { status: 'REJECTED', reason: '车牌不符' } } })
const state = page({ reviewDispatchLoading: async (no, body) => { sent = body; return { ...proof(fulfillment.dispatch.pendingCommand), stage: 'BEFORE_LOADING' } }, getDispatchTask: async () => rejected })
const state = page({ reviewDispatchLoading: async (no, body) => { sent = body; return { ...proof(lastCommand), stage: 'BEFORE_LOADING' } }, getDispatchTask: async () => rejected })
state.current = reviewTask(); state.reviewReason = '车牌不符'; await state.submitReview('REJECTED')
assert.equal(sent.decision, 'REJECTED'); assert.equal(state.rejectionReason, '车牌不符'); assert.equal(state.canReview, false)
assert.equal(state.stageLabel(state.current), '已退回客服处理'); assert.equal(state.canPick, false); assert.match(state.successMessage, /客服处理/)
})
test('review network uncertainty preserves exact decision reason version and key across restore and replay', async () => {
storage(); const sent = []; let fail = true
const state = page({ reviewDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: 'HTTP_404' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); state.reviewReason = '现场已核对'; await state.submitReview('APPROVED')
assert.equal(fulfillment.dispatch.pendingCommand.rejected, false); restoreFulfillmentDraft('operator:warehouse')
state.current.recordVersion = 99; state.reviewReason = '不得覆盖原文'; await state.recoverCommand()
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(sent[1].reason, '现场已核对'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('Admin wins a review race: definite conflict preserves command for explicit refresh and no automatic new approval', async () => {
storage(); let writes = 0
const state = page({ reviewDispatchLoading: async () => { writes++; throw { code: 'A0409', message: '版本已变化' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); await state.submitReview('APPROVED')
assert.equal(fulfillment.dispatch.pendingCommand.rejected, true); assert.equal(writes, 1)
state.discardRejected(); await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canReview, false); assert.equal(writes, 1)
})
test('review confirmation cannot submit a different version or a revoked gate', async () => {
for (const change of [state => { state.current.recordVersion = 2 }, state => { state.current.actions.LOADING_REVIEW.allowed = false }]) {
storage(); const state = page(); state.current = reviewTask(); uni.showModal = async () => { change(state); return { confirm: true } }
await state.submitReview('APPROVED'); assert.equal(fulfillment.dispatch.pendingCommand, null)
await state.submitReview('APPROVED'); assert.equal('pendingCommand' in fulfillment.dispatch, false)
}
})
test('wrong review result stage or stale refresh retains original command until authoritative confirmation', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_REVIEW', { recordVersion: 0, decision: 'REJECTED', reason: '车牌不符' })
const state = page({ reviewDispatchLoading: async () => proof(fulfillment.dispatch.pendingCommand) })
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
patchDispatchCommand({ result: { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'BEFORE_LOADING' } })
const stale = page({ getDispatchTask: async () => reviewTask({ recordVersion: 2 }) }); await stale.recoverCommand()
assert.ok(fulfillment.dispatch.pendingCommand); assert.match(stale.error.message, /版本早于/)
})
test('review UI exposes authoritative resources and reason without reviewer database IDs or extra dispatch review action', async () => {
const template = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).split('<script>')[0]
for (const field of ['resources?.vehicle?.plate', 'resources?.driver?.name', 'resources?.driver?.phone', 'reviews?.dispatchReview?.reason', 'rejectionReason']) assert.ok(template.includes(field))
@@ -263,33 +222,15 @@ test('review UI exposes authoritative resources and reason without reviewer data
assert.doesNotMatch(template, /reviewerId|vehicleId|driverId|DISPATCH_REVIEW/)
})
test('loading completion keeps original manifest through a network replay and immediately enters delivery', async () => {
storage(); let fail = true; const sent = []
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest' })
const state = page({ completeDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: '404' } }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
await state.recoverCommand(); restoreFulfillmentDraft('operator:warehouse'); state.current.recordVersion = 9; await state.recoverCommand()
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.canComplete, false); assert.equal(fulfillment.dispatch.pendingCommand, null)
await state.completeLoading(); await state.recoverCommand(); assert.equal(sent.length, 2)
})
test('loading completion requires an after-loading result and does not accept an intermediate stage as success', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
const state = page({ completeDispatchLoading: async () => ({ ...proof(fulfillment.dispatch.pendingCommand), stage: 'LOADING' }) })
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
})
test('documents already complete allow loading confirmation to return DELIVERING and recover without reposting', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'hash' })
let posts = 0, reads = 0
const state = page({ completeDispatchLoading: async () => { posts++; return { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'DELIVERING' } }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'DELIVERING', recordVersion: 3 }) } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'DELIVERING')
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
assert.match(state.successMessage, /已进入派送中/)
})
test('scanning received pallets defaults to real nine and eleven boxes without manually reentering estimates', async () => {
storage(); const sent = []
let rows = [9, 11].map((quantity, index) => item({ dispatchItemId: `received-${index}`, sourceKey: `PLAN:${index}`, sourceBusinessType: null, sourceBusinessNo: null, palletNo: `PALLET-${index}`, scheduledPackages: quantity, positions: [{ positionId: `position-${index}`, availablePackages: quantity, allocatedPackages: 0, pickedPackages: 0 }] }))
const state = page({ pickDispatchItem: async (number, id, body) => { sent.push(body.packages); rows = rows.map(row => row.dispatchItemId === id ? { ...row, pickedPackages: body.packages, positions: [] } : row); return proof(fulfillment.dispatch.pendingCommand, sent.length) }, getDispatchTask: async () => task({ items: rows, recordVersion: sent.length }) })
const state = page({ pickDispatchItem: async (number, id, body) => { sent.push(body.packages); rows = rows.map(row => row.dispatchItemId === id ? { ...row, pickedPackages: body.packages, positions: [] } : row); return proof(lastCommand, sent.length) }, getDispatchTask: async () => task({ items: rows, recordVersion: sent.length }) })
state.current = task({ items: rows })
state.handleScan('PALLET-0'); assert.equal(state.packages, 9); assert.equal(state.canPick, true); await state.submitPick()
state.handleScan('PALLET-1'); assert.equal(state.packages, 11); assert.equal(state.canPick, true); await state.submitPick()
@@ -312,16 +253,46 @@ test('same-task photo refresh stays on photos while a new task starts on picking
})
test('tab changes cannot interrupt an upload or pending command, or open photos before loading', () => {
storage(); const state = page(); state.busy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.busy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.current.stage = 'LOADING'; beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0 })
storage(); const state = page(); state.photoBusy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.photoBusy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.current.stage = 'LOADING'; state.requestBusy = true
state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
})
test('thirty scanned pallets confirm once without weight volume or pallet entry', async () => {
storage(); let sent
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'thirty', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: Array.from({ length: 30 }, (_, i) => ({ warehousePalletId: String(i), palletNo: `PALLET-${i}`, deliveryIndex: 1, pickedPackages: 10, weightKg: null, volumeCbm: null, evidence: [] })) }
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading(); await state.completeLoading()
assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false)
})
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0500', message: 'server error' }, { storageRentPreparation: true, code: 'A0409', message: '仓租费用尚未确认' }]) test('failed pick unlocks and submits current edited values: ' + failure.message, async () => {
const sent = []; let reads = 0
storage()
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } })
state.selectPallet(state.current.items[0]); await state.submitPick()
assert.equal(state.busy, false); assert.equal(reads, 0); assert.equal('pendingCommand' in fulfillment.dispatch, false)
state.current.recordVersion = 7; state.updatePackages({ detail: { value: 1 } }); await state.submitPick()
assert.equal(sent.length, 2); assert.equal(sent[1].recordVersion, 7); assert.equal(sent[1].packages, 1)
assert.notEqual(sent[0].idempotencyKey, sent[1].idempotencyKey)
state.selectTab('photos'); assert.equal(state.activeTab, 'photos')
})
test('failed review keeps editable reason and releases the request without a readback gate', async () => {
storage(); const sent = []
const state = page({ reviewDispatchLoading: async (no, body) => { sent.push(body); throw { network: true } } })
state.current = reviewTask(); state.reviewReason = 'first'; await state.submitReview('REJECTED')
assert.equal(state.busy, false); state.updateReviewReason({ detail: { value: 'changed' } }); await state.submitReview('REJECTED')
assert.equal(sent[1].reason, 'changed'); assert.notEqual(sent[0].idempotencyKey, sent[1].idempotencyKey)
})
test('in-flight pick blocks a second click and releases after failure', async () => {
storage(); let reject, writes = 0
const state = page({ pickDispatchItem: () => { writes++; return new Promise((_, failure) => { reject = failure }) } })
state.selectPallet(state.current.items[0]); const first = state.submitPick(); await state.submitPick()
assert.equal(writes, 1); reject({ network: true }); await first; assert.equal(state.busy, false)
})
test('invalid completion stage reports an error and still releases the page', async () => {
storage(); const state = page({ completeDispatchLoading: async () => ({ ...proof(lastCommand), stage: 'LOADING' }) })
await state.submitCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0 })
assert.match(state.error.message, /返回结果未确认/); assert.equal(state.busy, false)
})
+15 -7
View File
@@ -2,7 +2,7 @@ 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 { fulfillment, fulfillmentIdempotencyKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
import {
reviewDispatchLoading, completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, getDispatchTask, getOutboundTask,
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
@@ -23,6 +23,15 @@ test('dispatch network failures show recovery guidance and retain diagnostics',
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('outbound list and detail lead with the outbound number and label the task number separately', async () => {
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /class="business-code">\{\{ task\.outboundNo \|\| '—' \}\}/)
assert.match(page, /class="business-code">\{\{ current\.outboundNo \|\| '—' \}\}/)
assert.match(page, /任务号 \{\{ task\.taskNo \|\| '—' \}\}/)
assert.match(page, /任务号 \{\{ current\.taskNo \|\| '—' \}\}/)
assert.match(page, /@tap="openTask\(task\.taskNo\)"/)
})
test('native dispatch versions preserve explicit zero and reject missing or invalid versions', () => {
assert.equal(requireDispatchVersion(0), 0)
assert.equal(requireDispatchVersion('9007199254740993'), '9007199254740993')
@@ -162,17 +171,16 @@ test('dispatch and outbound adapters retain stable business identifiers and coun
assert.equal(outbound.items[0].positions[0].availablePackages, 3)
})
test('outbound draft restoration retains its retry key and stays scoped to operator and warehouse', () => {
test('outbound draft restoration discards request keys and stays scoped to operator and warehouse', () => {
storageMock(); resetFulfillmentSession(); restoreFulfillmentDraft('operator-a:warehouse-a')
updateFulfillmentDraft('outbound', { currentTaskNo: 'OUT-001' })
const first = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 })
assert.equal(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
assert.notEqual(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
fulfillment.outbound.currentTaskNo = ''; assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true)
assert.equal(fulfillment.outbound.currentTaskNo, 'OUT-001')
releaseFulfillmentKey('outbound', first.signature)
assert.notEqual(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false); assert.equal(fulfillment.outbound.currentTaskNo, '')
assert.throws(() => fulfillmentIdempotencyKey('dispatch', 'pick', {}), /完整原命令/)
assert.equal('pendingKeys' in fulfillment.outbound, false)
})
test('pages retain loading, offline, diagnostics and native loading completion followed by shared loading documents', async () => {
@@ -182,10 +190,10 @@ test('pages retain loading, offline, diagnostics and native loading completion f
assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/)
}
assert.doesNotMatch(dispatch, /departDispatch|canDepart|确认整车发车|等待后台安排派送/); assert.match(dispatch, /DispatchLoadingDocuments/); assert.match(dispatch, /completeDispatchLoading/); assert.match(dispatch, /pickingReconciled === true/)
assert.match(dispatch, /beginDispatchCommand/); assert.match(dispatch, /await getDispatchTask[\s\S]*finishDispatchCommand/)
assert.doesNotMatch(dispatch, /beginDispatchCommand|pendingCommand/); assert.match(dispatch, /submitCommand/)
assert.doesNotMatch(dispatch, /tripDepartureRequest|completeDispatchPicking|fulfillmentIdempotencyKey/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
assert.match(outbound, /outboundPickPayload/); assert.match(outbound, /await getOutboundTask[\s\S]*releaseFulfillmentKey/)
assert.match(outbound, /outboundPickPayload/); assert.match(outbound, /await getOutboundTask/); assert.doesNotMatch(outbound, /releaseFulfillmentKey/)
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
})
+20 -47
View File
@@ -1,43 +1,28 @@
import test from 'node:test'
import { runInNewContext } from 'node:vm'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { readFile, readdir } from 'node:fs/promises'
const read = path => readFile(new URL(path, import.meta.url), 'utf8')
test('H5 image copies only compiled assets and static nginx configuration', async () => {
const dockerfile = await read('../deploy/Dockerfile.h5')
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY dist/build/h5/ /srv/pda-h5/'])
assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
const ignore = await read('../.dockerignore')
assert.ok(ignore.split(/\r?\n/).includes('**'))
assert.ok(ignore.includes('!dist/build/h5/**'))
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
test('PDA H5 has no container publishing entry', async () => {
const files = await readdir(new URL('../deploy/', import.meta.url))
assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), [])
await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' })
})
test('static server serves the domain root and returns genuine missing-resource errors', async () => {
const nginx = await read('../deploy/nginx.conf')
assert.match(nginx, /listen 80;/)
assert.ok(nginx.includes('location /'))
assert.match(nginx, /root \/srv\/pda-h5;/)
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
assert.match(nginx, /Cache-Control "no-store" always/)
assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
assert.match(nginx, /location = \/healthz/)
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
})
test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
const compose = await read('../deploy/docker-compose.h5.test.yml')
assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
assert.match(compose, /context: \.\./)
assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
assert.match(compose, /container_name: pda-overseas-h5-test/)
assert.match(compose, /aliases:\s*\n\s*- pda-overseas-h5-test/)
assert.match(compose, /external: true/)
assert.match(compose, /name: firstleg-test-network/)
assert.match(compose, /expose:\s*\n\s*- "80"/)
assert.ok(compose.includes('http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/'))
assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
test('host nginx serves uploaded files and proxies only the API prefix', async () => {
const nginx = await read('../deploy/nginx.conf.template')
assert.ok(nginx.includes('root ${PDA_SITE_ROOT};'))
const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1]
assert.ok(page)
assert.match(page, /try_files \$uri \$uri\/ =404;/)
assert.match(page, /Cache-Control "no-store" always/)
assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/)
assert.match(nginx, /location \^~ \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;'))
assert.equal((nginx.match(/proxy_pass/g) || []).length, 1)
assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/)
assert.match(nginx, /client_max_body_size 55m;/)
assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/)
})
test('only H5 production overrides the toolchain asset base', async () => {
@@ -72,15 +57,3 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
}
})
test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
const compose = await read('../deploy/docker-compose.h5.production.yml')
const nginx = await read('../deploy/nginx.production.conf.template')
const docker = await read('../deploy/Dockerfile.h5.production')
assert.match(compose, /name: firstleg-production-network/)
assert.match(compose, /127\.0\.0\.1:/)
assert.doesNotMatch(compose, /test-network|gateway-overseas/)
assert.match(nginx, /location \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
})
+1 -2
View File
@@ -108,7 +108,7 @@ test('container UI locks and signs once, then renders the resolved destination g
for (const fact of ['已分配', '未分配', '可合托·非强制', '不合托', '加急', '普通']) assert.match(summary, new RegExp(fact))
assert.doesNotMatch(operation, /v-for="[^"]*inbound[^"]*"/)
assert.match(operation, /v-if="activeInbound"[\s\S]*activeInbound\.inboundCodes[\s\S]*activeInbound\.pendingPackages/)
assert.match(operation, /activeInbound\.operationMode === 'FBA_GROUP'[\s\S]*按 FBA 仓成组收货/)
assert.match(operation, /class="mode-banner"[^\n]*activeInbound\.destinationType === 'FBA'[^\n]*FBA 按箱数收货[^\n]*海外仓逐箱收货/)
assert.doesNotMatch(operation, /activeInbound\.customer|客户/)
assert.match(operation, /resolveCurrentCarton\(cartonMark\)/)
assert.match(operation, /operationMode !== 'DROPSHIP_PALLET'[\s\S]*receiveCurrentWholePallet/)
@@ -121,7 +121,6 @@ test('container UI locks and signs once, then renders the resolved destination g
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
assert.match(operation, /prepareReceivingContainer[\s\S]*validateReceivingWarehouse/)
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) {
assert.doesNotMatch(inboundApi, new RegExp(forbidden))
}
+48
View File
@@ -1,7 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { MODULES } from '../src/data/overseas-workbench.js'
import { navigateBackOr, navigateToUnique } from '../src/utils/navigation.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
const component = (source, dependencies) => new Function(...Object.keys(dependencies), source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
test('navigateBackOr returns through the stack and falls back when the stack is empty', () => {
const calls = []
globalThis.uni = {
@@ -36,3 +41,46 @@ test('navigateToUnique does not stack the current utility page twice', () => {
navigateToUnique('/pages/history/history')
assert.deepEqual(calls, [{ url: '/pages/history/history' }])
})
test('every registered page header returns home after a reload without page history', async (t) => {
const previousUni = globalThis.uni
const previousPages = globalThis.getCurrentPages
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
const { pages } = JSON.parse(await read('../src/pages.json'))
const header = component(await read('../src/components/PortalHeader.vue'), { PopupHeader: {}, navigateBackOr, navigateToUnique })
const calls = []
globalThis.uni = { reLaunch: (options) => calls.push(options), navigateBack: () => assert.fail('a reloaded page has no back stack') }
let checked = 0
for (const { path } of pages) {
const source = await read(`../src/${path}.vue`)
const tag = source.match(/<PortalHeader\b[^>]*\/>/)?.[0]
if (!tag) continue
assert.doesNotMatch(tag, /:fallback-url=/, path)
const fallbackUrl = tag.match(/\bfallback-url="([^"]*)"/)?.[1] || header.props.fallbackUrl.default
assert.equal(fallbackUrl, '/pages/home/home', path)
globalThis.getCurrentPages = () => [{ route: path }]
header.methods.goBack.call({ fallbackUrl })
assert.deepEqual(calls.at(-1), { url: '/pages/home/home' }, path)
checked++
}
assert.ok(checked > 0)
assert.equal(calls.length, checked)
})
test('home opens every enabled operation directly on a registered page', async (t) => {
const previousUni = globalThis.uni
t.after(() => { globalThis.uni = previousUni })
const { pages } = JSON.parse(await read('../src/pages.json'))
const registered = new Set(pages.map(({ path }) => `/${path}`))
const home = component(await read('../src/pages/home/home.vue'), { PortalHeader: {}, MODULES })
const calls = []
globalThis.uni = { navigateTo: (options) => calls.push(options) }
for (const group of MODULES) {
for (const operation of group.operations.filter((item) => item.available)) {
const count = calls.length
home.methods.openOperation.call({ groups: MODULES }, operation)
assert.equal(calls.length, count + 1, operation.name)
assert.ok(registered.has(calls.at(-1).url.split('?')[0]), operation.name)
}
}
})
+8 -7
View File
@@ -7,7 +7,7 @@ const deferred = () => { let resolve; const promise = new Promise(done => { reso
function page(overrides = {}) {
const calls = []
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key' }),
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
...overrides }
@@ -257,19 +257,19 @@ test('partial bound pallet confirmation sends only selected marks and preserves
assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false, { idempotencyKey: 'key' }])
})
test('discard confirmation retains its original request key until a definite successful response', async () => {
const released = []; const submitted = []
test('discard confirmation preserves inputs after preflight failure and starts a new checked request', async () => {
const submitted = []
const retry = deferred()
let attempt = 0
const { vm } = page({
confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('费用确认中'); await retry.promise },
releaseFulfillmentKey: (...args) => released.push(args), listCargoDiscardTasks: async () => []
fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => []
})
vm.discardCurrent = { discardNo: 'CD-RETRY' }
vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.equal(released.length, 0)
assert.equal(vm.discardBusy, false)
assert.ok(vm.discardTask)
assert.equal(vm.error.message, '费用确认中')
vm.successMessage = '上次作业已完成'
@@ -277,8 +277,9 @@ test('discard confirmation retains its original request key until a definite suc
assert.equal(vm.error, null)
assert.equal(vm.successMessage, '')
retry.resolve(); await retried
assert.deepEqual(submitted[0], submitted[1])
assert.deepEqual(released, [['outbound', 'signature']])
assert.deepEqual(submitted[0].slice(0, 6), submitted[1].slice(0, 6))
assert.notEqual(submitted[0][6].idempotencyKey, submitted[1][6].idempotencyKey)
assert.equal(vm.discardBusy, false)
assert.equal(vm.error, null)
assert.match(vm.successMessage, /已完成/)
})
+43
View File
@@ -274,6 +274,49 @@ test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile
}
})
test('H5 XMLHttpRequest timeout and abort reject once so the page can retry without an automatic upload replay', async () => {
prepareSession()
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest }
globalThis.window = {}; globalThis.location = { protocol: 'http:' }
globalThis.fetch = async () => new Response(new Blob(['photo'], { type: 'image/png' }))
const requests = []
globalThis.XMLHttpRequest = class {
constructor() { requests.push(this) }
open() {} setRequestHeader() {}
send() { queueMicrotask(() => requests.length === 1 ? this.ontimeout() : this.onabort()) }
}
try {
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
assert.equal(requests.length, 1); assert.equal(requests[0].timeout, 60000)
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /中断/.test(error.message))
assert.equal(requests.length, 2)
} finally {
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
}
})
test('H5 fetch upload aborts a stalled transfer, clears its timer, and permits a later upload', async () => {
prepareSession()
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }
let timeoutCallback, posts = 0, cleared = 0
globalThis.window = {}; globalThis.location = { protocol: 'http:' }; delete globalThis.XMLHttpRequest
globalThis.setTimeout = (callback, delay) => { assert.equal(delay, 60000); timeoutCallback = callback; return 'upload-timer' }
globalThis.clearTimeout = handle => { assert.equal(handle, 'upload-timer'); cleared++ }
globalThis.fetch = async (url, options) => {
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
if (++posts === 1) return new Promise((_resolve, reject) => { options.signal.addEventListener('abort', () => reject(new Error('aborted'))); queueMicrotask(timeoutCallback) })
return new Response(JSON.stringify({ code: '200', data: { fileId: 'retried-photo' } }), { status: 200 })
}
try {
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
assert.equal(posts, 1); assert.equal(cleared, 1)
assert.deepEqual(await overseasUpload('/pda/photo', 'blob:photo'), { fileId: 'retried-photo' })
assert.equal(posts, 2); assert.equal(cleared, 2)
} finally {
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
}
})
test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
const value = prepareSession()
const originalWindow = globalThis.window
+2 -3
View File
@@ -5,14 +5,13 @@ import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('pallet management is enabled through its dedicated real PDA route', async () => {
const [workbench, pages, home, module] = await Promise.all([
const [workbench, pages, home] = await Promise.all([
read('../src/data/overseas-workbench.js'), read('../src/pages.json'),
read('../src/pages/home/home.vue'), read('../src/pages/module/module.vue')
read('../src/pages/home/home.vue')
])
assert.match(workbench, /key: 'pallet'[\s\S]*available: true[\s\S]*route: '\/pages\/pallet\/pallet'/)
assert.match(pages, /pages\/pallet\/pallet/)
assert.match(home, /operation\.route \|\|/)
assert.match(module, /operation\.route \|\|/)
})
test('pallet API maps only the frozen overseas PDA operation endpoints', async () => {
+6 -6
View File
@@ -90,9 +90,9 @@ test('login has no committed demo credentials and inbound operation implements t
})
test('warehouse operations use dedicated real pages and contain no prototype task data', async () => {
const [operation, home, module, workbench, pages] = await Promise.all([
const [operation, home, 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.json')
read('../src/data/overseas-workbench.js'), read('../src/pages.json')
])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype|模拟成功/)
@@ -101,7 +101,7 @@ test('warehouse operations use dedicated real pages and contain no prototype tas
}
assert.doesNotMatch(workbench, /key: 'stocktake'/)
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 \|\|/)
assert.match(home, /operation\.route \|\|/)
})
test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => {
@@ -130,7 +130,7 @@ test('candidate containers use compact card navigation and a small refresh actio
assert.match(operation, /class="candidate-facts"[\s\S]*总订单[\s\S]*入仓码[\s\S]*已生成计划[\s\S]*未操作/)
assert.match(operation, /class="candidate-destinations"/)
assert.match(operation, /计划到仓:\{\{ formatPlannedArrival\(item\.plannedArrivalAt\) \}\}/)
assert.match(operation, /item\.blockReason \|\| '当前柜入库计划尚未齐全'/)
assert.match(operation, /selected\?\.blockReason \|\| '当前柜入库计划尚未齐全'/)
assert.doesNotMatch(operation, /预计到港/)
assert.doesNotMatch(operation, /item\.receivedPackages[\s\S]*item\.totalPackages[\s\S]*progress-fill/)
})
@@ -164,7 +164,7 @@ test('return receipt is a real PDA flow backed by the dedicated return APIs', as
assert.match(page, /startedBy: this\.startedBy/)
assert.match(page, /idempotencyKey: key\.value/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(warehouseWork, /returnReceipt:[^\n]*pendingKeys/)
assert.doesNotMatch(warehouseWork, /pendingKeys/)
assert.doesNotMatch(page, /customerId/)
})
@@ -182,7 +182,7 @@ test('return packing is a separate same-customer inventory operation', async ()
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
assert.match(page, /idempotencyKey: key\.value/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(warehouseWork, /returnPacking:[^\n]*pendingKeys/)
assert.doesNotMatch(warehouseWork, /pendingBatch/)
assert.doesNotMatch(page.match(/<template>([\s\S]*?)<\/template>/)?.[1] || '', /customerId|warehouseId/)
})
+33 -2
View File
@@ -11,12 +11,15 @@ test('box merge component compiles both its script and template', () => {
compileScript(descriptor, { id: 'box-merge' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
})
function instance() {
function instance(runtime = {}) {
const boxes = [{cartonMark:'A',customerId:'C1',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}]
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark) })
const context = vm.createContext({ reactive: value => value, ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark), ...runtime })
vm.runInContext(readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8').replace(/^import .*$/gm, '').replace(/export /g, ''), context)
const component = vm.runInContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), context)
const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
work.save = () => {}
Object.defineProperty(work, 'canComplete', { get: () => component.computed.canComplete.call(work) })
return {work,component}
}
test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
@@ -31,6 +34,34 @@ test('new plan requires scanning each source and preserves locked SKU quantities
assert.equal(component.computed.canComplete.call(work),true)
})
test('partial merge preserves the scanned snapshot after failure and accepts a fresh scan without locking', async () => {
let available = 10
const calls = [], errors = []
const { work } = instance({
scanReturnCarton: async () => ({ cartonMark: 'A', customerId: 'C1', items: [{ skuCode: 'SKU1', quantity: available }] }),
mergeReturnBoxes: async request => {
calls.push(JSON.parse(JSON.stringify(request)))
if (request.expectedSourceQuantities[0].quantity !== available) throw new Error('来源箱库存已变化')
available -= request.sourceDeductions[0].quantity
if (calls.length === 1) throw new Error('response lost after commit')
return { label: { cartonMark: 'NEW' } }
}
})
work.plan = null; work.choose('NEW'); work.$emit = (name, value) => { if (name === 'error') errors.push(value) }
await work.scanSource('A'); work.setDeduction('A', 'SKU1', 3)
work.dimensions = { length: 10, width: 10, height: 10, weight: 1 }
await work.complete(); await work.complete()
assert.equal(available, 7)
assert.equal(work.busy, false)
assert.equal(errors.at(-1).message, '来源箱库存已变化')
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
work.remove('A'); await work.scanSource('A'); work.setDeduction('A', 'SKU1', 2)
await work.complete()
assert.equal(available, 5)
assert.deepEqual(calls.map(call => call.expectedSourceQuantities[0].quantity), [10, 10, 7])
assert.equal(work.busy, false)
})
test('planned source carton marks are visible before scanning', () => {
const {work, component} = instance()
work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
+1 -1
View File
@@ -37,7 +37,7 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r
}
await startReturnReceipt(' RT-001 ')
await findReturnSkuCandidates(' SKU-1 ')
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
const confirm = { returnNo: 'RT-001', expectedReceivedQuantity: 0, trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
await confirmReturnReceipt(confirm)
assert.deepEqual(calls.map((item) => item.url), [
'/api/pda/v1/last-leg/return-receipts/start',
+58 -10
View File
@@ -122,13 +122,12 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime
const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context)
vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context)
const instance = Object.assign({}, component.methods, {
unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
receiptResults: [], unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
selectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photoFileIds: [], photoNames: [], photoUrls: {}, trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
quantity: () => 2, boxCount: () => 1
})
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
return instance
}
@@ -156,7 +155,7 @@ test('PALLET page submits the required palletCode through the actual confirmatio
assert.equal(calls.length, 1)
assert.equal(calls[0].palletCode, 'PLT-TEST')
assert.equal(calls[0].unitCode, undefined)
assert.deepEqual(Object.keys(calls[0]).sort(), ['height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort())
assert.deepEqual(Object.keys(calls[0]).sort(), ['expectedReceivedQuantity', 'height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort())
assert.equal(calls[0].unitType, 'PALLET')
assert.equal(calls[0].totalBoxCount, 1)
assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['boxCount', 'receivedQuantity', 'skuCode'].sort())
@@ -232,7 +231,7 @@ test('one submission signs multiple mixed-SKU parcels sharing one waybill and in
assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2'])
})
test('failed multi-parcel batch resumes immutable requests without re-signing a successful parcel', async () => {
test('failed multi-parcel receipt keeps successful results and accepts edits to remaining rows', async () => {
const saved = new Map(), calls = []
let failSecond = true
const confirm = async request => {
@@ -243,16 +242,65 @@ test('failed multi-parcel batch resumes immutable requests without re-signing a
const page = setup(confirm, saved)
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [physicalRow({ 'SKU-TEST': 1 }), physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
assert.equal(page.receiptResults[0].labels[0].cartonMark, 'RP-1'); assert.equal(page.boxRows.length, 1); assert.equal(page.busy, false)
failSecond = false
const restored = setup(confirm, saved)
const restored = page; page.boxRows[0].items['SKU-TEST'] = 3
await restored.confirmReceipt()
assert.equal(calls.length, 3)
assert.equal(calls[1].idempotencyKey, calls[2].idempotencyKey)
assert.deepEqual(calls[1], calls[2])
assert.notEqual(calls[1].idempotencyKey, calls[2].idempotencyKey)
assert.equal(calls[2].items[0].receivedQuantity, 3)
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 1, 1])
assert.equal(restored.result.labels.length, 2)
})
test('lost receipt response keeps the old quantity precondition and does not lock editing or refresh', async () => {
const calls = []
let received = 0
const page = setup(async request => {
calls.push(JSON.parse(JSON.stringify(request)))
if (request.expectedReceivedQuantity !== received) throw new Error('退货单实收数量已变化')
received += request.items[0].receivedQuantity
if (calls.length === 1) throw new Error('response lost after commit')
return { unitType: 'PARCEL', fullyMatched: false }
}, new Map(), 'operator:warehouse', {
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: received }], photos: [{ fileId: 'PHOTO' }] }),
downloadReturnPalletPhoto: async () => 'photo-url'
})
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
await page.confirmReceipt()
assert.equal(received, 2)
assert.equal(page.busy, false)
assert.equal(page.boxRows.length, 1)
assert.equal(page.error.message, '退货单实收数量已变化')
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
// 用户刷新后,按当前实收量登记下一份实物。
await page.reloadCurrent()
assert.equal(page.boxRows[0].confirmed, false)
assert.deepEqual(Object.keys(page.boxRows[0].items), [])
await page.confirmReceipt()
assert.equal(received, 2, '刷新后原包裹不能再次提交')
assert.equal(calls.length, 2)
page.boxRows[0] = physicalRow({ 'SKU-TEST': 3 })
await page.confirmReceipt()
assert.equal(received, 5)
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 0, 2])
assert.equal(page.busy, false)
})
test('refresh with unchanged receipt quantities preserves editable input after a rejected request', async () => {
const page = setup(async () => { throw new Error('not committed') }, new Map(), 'operator:warehouse', {
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photos: [{ fileId: 'PHOTO' }] }),
downloadReturnPalletPhoto: async () => 'photo-url'
})
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt(); await page.reloadCurrent()
assert.equal(page.boxRows[0].items['SKU-TEST'], 2)
assert.equal(page.busy, false)
})
test('partial receipt continues the same order with fresh pallet measurements', async () => {
const page = setup(async () => ({}))
page.finishReceipt([{ fullyMatched: false }])
@@ -266,7 +314,7 @@ test('partial receipt continues the same order with fresh pallet measurements',
assert.equal(page.palletWeight, '')
})
test('PALLET response failure keeps the draft and same idempotency key for retry', async () => {
test('PALLET response failure keeps editable input and generates a new key for the next click', async () => {
const calls = []
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
await page.confirmReceipt()
@@ -275,7 +323,7 @@ test('PALLET response failure keeps the draft and same idempotency key for retry
assert.equal(page.error.message, 'Response lost')
await page.confirmReceipt()
assert.equal(calls.length, 2)
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
})
test('continue signing returns to SKU scanning and accepts a fresh scan', () => {
-3
View File
@@ -1,6 +1,5 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
@@ -58,8 +57,6 @@ test('retry reuses the preparation key; exempt and already consumed commands can
assert.deepEqual(calls[0].data, original)
assert.equal(calls.length, 2)
}
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
assert.match(page, /!cause\.storageRentPreparation/)
})
test('invalid dispatch version or missing command identity cannot start a reservation', async () => {
+5 -7
View File
@@ -12,7 +12,7 @@ import {
reportRelabelException, reviewRelabelTask, skipRelabelUnit
} from '../src/api/relabel.js'
import {
releaseWarehouseKey, resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork,
resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork,
warehouseIdempotencyKey, warehouseWork
} from '../src/stores/warehouse-work.js'
@@ -126,7 +126,6 @@ test('relabel review opens a pending task as customer-confirmation read-only det
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\)/)
@@ -145,8 +144,8 @@ test('relabel review opens a pending task as customer-confirmation read-only det
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, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做/)
assert.match(page, /current\.reviewDecision === 'REWORK' && current\.reviewRemark[\s\S]*\{\{ current\.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\)/)
@@ -165,11 +164,10 @@ test('warehouse drafts are isolated by operator and warehouse and retain retry k
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)
assert.notEqual(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, '')
})
@@ -191,7 +189,7 @@ test('new operation pages wire scans, manual inputs, conflict refresh, loading a
}
for (const page of [stocktake, move, relabel]) {
assert.match(page, /warehouseIdempotencyKey/)
assert.match(page, /releaseWarehouseKey/)
assert.doesNotMatch(page, /releaseWarehouseKey/)
assert.match(page, /isConflict[\s\S]*刷新/)
}
assert.match(stocktake, /expectedVersion: item\.version/)