Merge origin/release/test and preserve storage rent checks with request unlock
This commit is contained in:
@@ -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 \|\| '未绑定库位'/)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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, /已完成/)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user