Files
pda-overseas-uniapp/tests/receiving-lifecycle.test.mjs

236 lines
16 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import * as store from '../src/stores/receiving.js'
import { normalizeReceivingSession, toReceivingError } from '../src/api/receiving-adapter.js'
const source = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
const defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
const sessionValue = (patch = {}) => normalizeReceivingSession({ containerNo: 'CAB-A', preparedAt: '2026-10-02T10:00:00',
overseasWarehouseId: '900', status: 'SIGNED', inbounds: [{ inboundCode: 'IN-A', customerId: '100' }],
photos: ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'].map((role, i) => ({ role, fileId: String(i + 1), downloadPath: `/signed/${i + 1}` })),
domesticLoadingPhotos: Array.from({ length: 5 }, (_, i) => ({ evidenceType: `PHOTO_${i}`, downloadPath: `/domestic/${i}` })), ...patch })
function page(overrides = {}) {
const deps = { ...store, session: { tenantId: '100', currentUser: { id: 'operator', warehouseId: '900' } },
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, requireSession: () => true,
startScanBroadcast: () => () => {}, playErrorSound() {}, playSuccessSound() {},
receivingPhase: store.receivingPhase, toReceivingError: error => error, ...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const vm = { ...definition.data(), _pageActive: true, _scanEpoch: 0 }
for (const [name, method] of Object.entries(definition.methods)) vm[name] = method.bind(vm)
for (const [name, descriptor] of Object.entries(definition.computed)) Object.defineProperty(vm, name,
typeof descriptor === 'function' ? { get: () => descriptor.call(vm) } : { get: () => descriptor.get.call(vm), set: value => descriptor.set.call(vm, value) })
vm.moduleKey = 'inbound'; vm.validateReceivingWarehouse = value => value; vm.showToast = () => {}
return { vm, definition, deps }
}
test('same current readback retains three signed previews; collapsed unversioned domestic images issue no downloads', async () => {
store.resetReceivingSession(); const calls = []
const download = async path => { calls.push(path); return `local:${path}` }
const { vm } = page({ downloadCurrentUnloadPhoto: download, downloadCurrentDomesticLoadingPhoto: download })
await vm.applyCurrentReceiving(sessionValue({ status: 'WAIT_SIGN' })); assert.equal(calls.length, 8)
await vm.applyCurrentReceiving(sessionValue()); await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 8)
await vm.toggleDomesticPhotos(); assert.equal(calls.length, 13)
await vm.restoreDomesticPhotoPreviews(); assert.equal(calls.length, 13)
// Domestic DTO has no file identity/version: a new response must not reuse an old image.
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
})
test('editing and rejecting a pallet cannot submit using the previous successful validation', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
store.receiving.resolvedPallet = { palletCode: 'PALLET-OLD', compatible: true, version: 1 }
let writes = 0
const { vm } = page({ toReceivingError,
resolveCurrentPallet: async () => { throw Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404', requestId: 'scan-request', traceId: 'scan-trace' }) },
receiveCurrentBulk: async () => { writes++ },
})
t.after(() => vm.suspendReceiving())
vm.palletInput = 'PALLET-OLD'; vm.packageCount = 3
assert.equal(vm.canSubmitReceipt, true)
vm.palletInput = 'PALLET-WRONG'
assert.equal(vm.canSubmitReceipt, false)
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
await vm.resolvePallet(vm.palletInput)
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.packageCount, '')
assert.equal(vm.activeTarget, 'pallet')
assert.equal(vm.error.message, '托盘不属于当前目的仓,请重扫')
assert.equal(vm.error.requestId, 'scan-request')
assert.equal(vm.error.traceId, 'scan-trace')
assert.deepEqual(toasts, [{ message: '托盘不属于当前目的仓,请重扫', type: 'error' }])
await vm.submitReceipt()
assert.equal(writes, 0)
})
test('an incompatible pallet never reports success and a corrected scan can be validated normally', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const calls = []; let successes = 0
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
resolveCurrentPallet: async (palletCode, inboundCode) => {
calls.push({ palletCode, inboundCode })
return { palletCode, compatible: palletCode === 'PALLET-VALID', version: 2, currentReceivedPackages: 3 }
},
})
t.after(() => vm.suspendReceiving())
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
await vm.resolvePallet('PALLET-WRONG')
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.canSubmitReceipt, false)
assert.equal(successes, 0)
assert.deepEqual(toasts, [{ message: '托盘不匹配,请重扫', type: 'error' }])
await vm.resolvePallet('PALLET-VALID')
assert.deepEqual(calls, [{ palletCode: 'PALLET-WRONG', inboundCode: 'IN-A' }, { palletCode: 'PALLET-VALID', inboundCode: 'IN-A' }])
assert.equal(vm.resolvedPallet.palletCode, 'PALLET-VALID')
assert.equal(vm.error, null)
assert.equal(vm.packageCount, 3)
assert.equal(vm.canSubmitReceipt, true)
assert.equal(vm.activeTarget, 'quantity')
assert.equal(successes, 1)
})
test('late pallet validation cannot replace edited input and only the corrected pallet can be received', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), receipts = [], calls = [], toasts = []; let successes = 0
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
resolveCurrentPallet: (palletCode, inboundCode) => {
calls.push({ palletCode, inboundCode })
return palletCode === 'PALLET-A' ? pending.promise : Promise.resolve({ palletCode, compatible: true, version: 2, currentReceivedPackages: 3 })
},
receiveCurrentBulk: async payload => { receipts.push(payload); return {} },
getCurrentReceiving: async () => sessionValue(),
})
t.after(() => vm.suspendReceiving())
vm.showToast = (message, type) => toasts.push({ message, type }); vm.applyCurrentReceiving = async () => {}
const validation = vm.resolvePallet('PALLET-A')
vm.palletInput = 'PALLET-B'
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
await validation
assert.equal(vm.palletInput, 'PALLET-B')
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.packageCount, '')
assert.equal(vm.canSubmitReceipt, false)
assert.equal(vm.busy, false)
assert.equal(successes, 0)
assert.deepEqual(toasts, [])
await vm.submitReceipt(); assert.equal(receipts.length, 0)
await vm.resolvePallet('PALLET-B')
assert.equal(vm.canSubmitReceipt, true)
assert.equal(successes, 1)
await vm.submitReceipt()
assert.deepEqual(calls, [{ palletCode: 'PALLET-A', inboundCode: 'IN-A' }, { palletCode: 'PALLET-B', inboundCode: 'IN-A' }])
assert.deepEqual(receipts.map(({ palletCode }) => palletCode), ['PALLET-B'])
})
test('obsolete pallet failures cannot replace current input or feedback', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), toasts = []; let failures = 0
const { vm } = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playErrorSound: () => failures++ })
t.after(() => vm.suspendReceiving()); vm.showToast = (message, type) => toasts.push({ message, type })
const validation = vm.resolvePallet('PALLET-A')
vm.palletInput = 'PALLET-B'
pending.reject(Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404' }))
await validation
assert.equal(vm.palletInput, 'PALLET-B'); assert.equal(vm.error, null)
assert.equal(vm.resolvedPallet, null); assert.equal(vm.canSubmitReceipt, false)
assert.equal(vm.busy, false); assert.equal(failures, 0); assert.deepEqual(toasts, [])
})
test('late pallet validation cannot cross a receiving context or page lifetime', async t => {
for (const [name, change] of [
['carton', () => { store.receiving.resolvedCarton = { inboundCode: 'IN-B', cartonMark: 'IN-B#1', destinationType: 'FBA' } }],
['container', () => store.setCurrentReceiving(sessionValue({ containerNo: 'CAB-B' }))],
['operator', ({ deps }) => { deps.session.currentUser = { id: 'another-operator', warehouseId: '900' } }],
['warehouse', ({ deps }) => { deps.session.currentUser.warehouseId = '901' }],
['hide and return', ({ vm, definition }) => { definition.onHide.call(vm); definition.onShow.call(vm) }],
]) await t.test(name, async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), toasts = []; let successes = 0
const context = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playSuccessSound: () => successes++ })
const { vm } = context
t.after(() => vm.suspendReceiving()); vm.bootstrap = async () => {}; vm.showToast = (message, type) => toasts.push({ message, type })
const validation = vm.resolvePallet('PALLET-A')
change(context)
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
await validation
assert.equal(vm.resolvedPallet, null); assert.equal(vm.packageCount, '')
assert.equal(vm.canSubmitReceipt, false); assert.equal(vm.busy, false)
assert.equal(successes, 0); assert.deepEqual(toasts, [])
})
})
test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => {
store.resetReceivingSession(); let calls = 0
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls, 3)
const changed = sessionValue(); changed.photos[0].fileId = 'NEW'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 4)
deps.session.tenantId = '200'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 7)
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B' })); assert.equal(calls, 10)
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B', inbounds: [{ inboundCode: 'IN-OTHER', customerId: '201' }] })); assert.equal(calls, 13)
vm.invalidatePhotoPreview(store.receiving.photoSlots[0]); await vm.restorePhotoPreviews(); assert.equal(calls, 14)
})
test('overlapping current refresh joins inflight photo download and failed fetch remains retryable', async () => {
store.resetReceivingSession(); const pending = defer(); let calls = 0
const { vm } = page({ downloadCurrentUnloadPhoto: async path => { calls++; await pending.promise; return `local:${path}` } })
const first = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
const second = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve(); assert.equal(calls, 3)
pending.resolve(); await Promise.all([first, second]); assert.equal(calls, 3)
const photo = { downloadPath: '/retry' }; let attempts = 0
const download = async () => { if (++attempts === 1) throw new Error('offline'); return 'local:retry' }
await assert.rejects(store.restoreReceivingPhoto(photo, download), /offline/)
assert.equal(photo.status, 'error'); assert.equal(await store.restoreReceivingPhoto(photo, download), 'local:retry'); assert.equal(attempts, 2)
})
test('hide and unload discard queued scans and cancel drain scheduled by an in-flight request; return refreshes facts', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); const { vm, definition } = page(); let submissions = 0, refreshes = 0
vm.dispatchHardwareScan = () => { submissions++ }; vm.bootstrap = async () => { refreshes++ }
vm.scanQueue = [{ value: 'SECOND', target: 'carton' }]
const running = vm.run('already sent', async () => { submissions++; await pending.promise })
definition.onHide.call(vm); pending.resolve(); await running
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1); assert.equal(vm.scanQueue.length, 0)
definition.onShow.call(vm); assert.equal(refreshes, 1); assert.equal(submissions, 1)
vm.scanQueue.push({ value: 'THIRD' }); vm.scheduleScanDrain(); definition.onUnload.call(vm)
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1)
})
test('hide during cargo resolution prevents the subsequent automatic dropship write, including quick return', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); let writes = 0
const { vm, definition } = page({ resolveCurrentCarton: () => pending.promise, receiveCurrentWholePallet: async () => { writes++ } })
vm.bootstrap = async () => {}
const running = vm.resolveCarton('DP-1'); definition.onHide.call(vm); definition.onShow.call(vm)
pending.resolve({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }); await running
assert.equal(writes, 0); assert.equal(vm.scanQueue.length, 0)
})
test('already sent receipt retains successful server fact after hide without draining another scan', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); let writes = 0, reads = 0
const { vm, definition } = page({ resolveCurrentCarton: async () => ({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }),
receiveCurrentWholePallet: async () => { writes++; return pending.promise },
getCurrentReceiving: async () => { reads++; return sessionValue({ receivedPackages: 1 }) } })
const running = vm.resolveCarton('DP-1'); await Promise.resolve(); assert.equal(writes, 1)
vm.scanQueue.push({ value: 'DP-2', target: 'carton' }); definition.onHide.call(vm)
pending.resolve({ receivedPackages: 1 }); await running
assert.equal(writes, 1); assert.equal(reads, 1); assert.equal(store.receiving.current.receivedPackages, 1)
assert.equal(store.receiving.lastReceipt.receivedPackages, 1); assert.equal(vm.scanQueue.length, 0)
})
test('late old photo download never overwrites replacement or cross-account preview', async () => {
store.resetReceivingSession(); const first = defer(), calls = []
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async path => { calls.push(path); if (calls.length <= 3) await first.promise; return `local:${path}` } })
const old = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
deps.session.tenantId = 'new-customer'
const changed = sessionValue(); changed.photos[0] = { ...changed.photos[0], fileId: 'new', downloadPath: '/signed/new' }
await vm.applyCurrentReceiving(changed); assert.equal(calls.length, 6)
first.resolve(); await old
assert.equal(store.receiving.photoSlots[0].fileId, 'new'); assert.equal(store.receiving.photoSlots[0].localPath, 'local:/signed/new')
})