235 lines
14 KiB
JavaScript
235 lines
14 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
const source = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
|
|
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } }
|
|
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() {},
|
|
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 }
|
|
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
|
const vm = definition.data()
|
|
for (const [name, fn] of Object.entries(definition.methods)) vm[name] = fn.bind(vm)
|
|
for (const [name, fn] of Object.entries(definition.computed)) Object.defineProperty(vm, name, { get: () => fn.call(vm) })
|
|
return { vm, calls, definition }
|
|
}
|
|
|
|
test('scanning a discard outbound document opens the discard workflow with its linked number', () => {
|
|
const { vm } = page()
|
|
vm.discardTasks = [{ discardNo: 'CD-1', outboundNo: 'OB-1', palletNo: 'P-1', status: 'PENDING' }]
|
|
vm.openScannedTask = () => { throw new Error('must not enter regular inventory picking') }
|
|
vm.handleScan('OB-1')
|
|
assert.equal(vm.discardCurrent.discardNo, 'CD-1')
|
|
assert.equal(vm.discardCurrent.outboundNo, 'OB-1')
|
|
})
|
|
|
|
test('退货废弃一箱五件分别展示,混装SKU按箱唛去重', () => {
|
|
const { vm } = page()
|
|
vm.discardTask = { cartonMarks: ['RETURN-BOX-1'], skus: [{ quantityUnit: '件', plannedPackages: 5 }] }
|
|
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
|
|
vm.discardTask.skus = [{ quantityUnit: '件', plannedPackages: 2 }, { quantityUnit: '件', plannedPackages: 3 }]
|
|
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
|
|
vm.discardTask = { cartonMarks: ['BOX-1', 'BOX-1'], skus: [{ quantityUnit: '箱', plannedPackages: 1 }] }
|
|
assert.equal(vm.discardQuantitySummary, '1 箱')
|
|
})
|
|
|
|
test('discard pallet resolution passes the selected discard number', async () => {
|
|
let received
|
|
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
|
|
received = args
|
|
return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
|
|
} })
|
|
vm.discardCurrent = { discardNo: 'CD-2' }
|
|
await vm.resolveDiscardPallet('P-1')
|
|
assert.deepEqual(received, ['P-1', 'CD-2'])
|
|
})
|
|
|
|
test('点击打开废弃作业使用输入框值,不把 tap 事件当作托盘号', async () => {
|
|
let received
|
|
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
|
|
received = args
|
|
return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
|
|
} })
|
|
vm.discardCurrent = { discardNo: 'CD-2' }
|
|
vm.discardPalletCode = ' P-1 '
|
|
await vm.resolveDiscardPallet({ type: 'tap', detail: { x: 10, y: 20 } })
|
|
assert.deepEqual(received, ['P-1', 'CD-2'])
|
|
assert.equal(vm.discardPalletCode, 'P-1')
|
|
})
|
|
|
|
test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,范围不扩展到其他单据', async () => {
|
|
const requests = []
|
|
const tasks = [
|
|
{ discardNo: 'CD-1', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-1'], skus: [], status: 'PENDING' },
|
|
{ discardNo: 'CD-1', palletNo: 'P-2', scanMode: 'CARTON', cartonMarks: ['BOX-2'], skus: [], status: 'PENDING' },
|
|
{ discardNo: 'CD-OTHER', palletNo: 'P-3', scanMode: 'CARTON', cartonMarks: ['BOX-3'], skus: [], status: 'PENDING' },
|
|
]
|
|
const { vm } = page({ resolveCargoDiscardPallet: async code => {
|
|
requests.push(code)
|
|
return tasks.find(task => task.palletNo === code || task.cartonMarks.includes(code))
|
|
} })
|
|
vm.discardTasks = tasks
|
|
vm.openDiscardOrder('CD-1')
|
|
await Promise.resolve()
|
|
assert.equal(vm.discardCurrentTasks.length, 2)
|
|
await vm.resolveDiscardPallet('P-1')
|
|
assert.equal(vm.error, null)
|
|
await vm.resolveDiscardPallet('BOX-2')
|
|
assert.equal(vm.discardTask.palletNo, 'P-2')
|
|
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
|
await vm.resolveDiscardPallet('BOX-3')
|
|
assert.deepEqual(requests, ['P-1', 'BOX-2'])
|
|
assert.equal(vm.discardTask.palletNo, 'P-2')
|
|
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
|
})
|
|
|
|
test('discard requires restored photos and retains task while native camera returns', async () => {
|
|
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} })
|
|
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 3 }
|
|
assert.equal(vm.canConfirmDiscard, false)
|
|
vm.discardPhotoReady = true; assert.equal(vm.canConfirmDiscard, false)
|
|
vm.discardPhotos = [{ fileId: '101' }]; assert.equal(vm.canConfirmDiscard, true)
|
|
vm.discardPhotoBusy = true; assert.equal(vm.canConfirmDiscard, false)
|
|
let loads = 0; vm.startScanner = () => {}; vm.loadTasks = () => { loads++ }
|
|
definition.onShow.call(vm)
|
|
assert.equal(loads, 0); assert.equal(vm.discardTask.palletNo, 'PALLET')
|
|
})
|
|
|
|
test('discard list failure blocks confirmation and stale downloads cannot replace a new pallet', async () => {
|
|
const pending = deferred()
|
|
const { vm } = page({ listCargoDiscardPhotos: async () => { throw new Error('offline photos') }, downloadCargoDiscardPhoto: () => pending.promise })
|
|
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'A', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
|
|
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
|
await vm.restoreDiscardPhotos(); assert.equal(vm.canConfirmDiscard, false)
|
|
const first = vm.loadDiscardPhoto('101'); vm.discardTask = { palletNo: 'B' }; vm.resetDiscardPhotos()
|
|
pending.resolve('old-photo-url'); await first; assert.deepEqual(vm.discardPhotoUrls, {})
|
|
})
|
|
|
|
test('return pallet rows sum carton counts without changing raw pick lines', () => {
|
|
const { vm } = page()
|
|
const cartons = Array.from({ length: 5 }, (_, index) => ({ outboundPalletItemId: index + 1, palletNo: 'PALLET', plannedPackages: 1, pickedPackages: index < 3 ? 1 : 0, positions: [] }))
|
|
const loose = { outboundPalletItemId: 6, palletNo: '', plannedPackages: 2, pickedPackages: 0, positions: [] }
|
|
vm.current = { outboundType: 'RETURN_OUTBOUND', items: [...cartons, loose, { outboundPalletItemId: 7, palletNo: 'OTHER', plannedPackages: 2, pickedPackages: 1, positions: [] }] }
|
|
const before = JSON.stringify(vm.current.items)
|
|
assert.equal(vm.displayItems.length, 3)
|
|
assert.equal(vm.displayItems[0].plannedPackages, 5)
|
|
assert.equal(vm.displayItems[0].pickedPackages, 3)
|
|
assert.equal(vm.displayItems[1], loose)
|
|
assert.equal(vm.displayItems[2].palletNo, 'OTHER')
|
|
assert.equal(JSON.stringify(vm.current.items), before)
|
|
assert.match(vm.scanLabel, /扫描托盘号/)
|
|
assert.match(vm.scanPlaceholder, /扫描托盘号/)
|
|
cartons.forEach(item => { item.pickedPackages = 1 })
|
|
assert.equal(vm.displayItems[0].pickedPackages, 5)
|
|
})
|
|
|
|
test('other outbound types preserve individual selectable lines', () => {
|
|
const { vm } = page()
|
|
const items = [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }, { palletNo: 'PALLET', plannedPackages: 2, pickedPackages: 0, positions: [] }]
|
|
vm.current = { outboundType: 'NORMAL', items }
|
|
assert.equal(vm.displayItems, items)
|
|
assert.equal(vm.hasReturnPallet, false)
|
|
})
|
|
|
|
test('completed and cancelled tasks cannot submit pallet picking or task completion', async () => {
|
|
for (const status of ['COMPLETED', 'CANCELLED']) {
|
|
const { vm, calls } = page()
|
|
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 1, positions: [] }] }
|
|
await vm.handleScan('PALLET'); await vm.submitPalletPick('PALLET'); await vm.completeTask()
|
|
assert.deepEqual(calls, [])
|
|
}
|
|
})
|
|
|
|
test('pending and in-progress tasks still submit exactly once while busy', async () => {
|
|
for (const status of ['PENDING', 'IN_PROGRESS']) {
|
|
const pending = deferred(); let submits = 0
|
|
const { vm } = page({ pickOutboundPallet: async () => { submits++; await pending.promise } })
|
|
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }] }
|
|
const first = vm.handleScan('PALLET'); const second = vm.handleScan('PALLET')
|
|
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
|
|
}
|
|
})
|
|
|
|
test('failed new pallet lookup clears the previous selectable pallet and preserves retry code', async () => {
|
|
const { vm } = page({ resolveCargoDiscardPallet: async () => { throw new Error('offline') } })
|
|
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [] }
|
|
await vm.resolveDiscardPallet('NEW')
|
|
assert.equal(vm.discardTask, null); assert.equal(vm.canConfirmDiscard, false)
|
|
assert.equal(vm.discardPalletCode, 'NEW'); assert.equal(vm.error.message, 'offline'); assert.equal(vm.discardLoading, false)
|
|
})
|
|
|
|
test('discard pallet scans do not overlap and cannot leave the previous pallet ready to submit', async () => {
|
|
const pending = deferred(); let resolves = 0
|
|
const { vm } = page({ resolveCargoDiscardPallet: async () => { resolves++; return pending.promise } })
|
|
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
|
|
const first = vm.resolveDiscardPallet('NEW')
|
|
const second = vm.resolveDiscardPallet('SECOND')
|
|
const canConfirmDuringLoad = vm.canConfirmDiscard
|
|
pending.resolve({ discardNo: 'DISCARD', palletNo: 'NEW', status: 'PENDING', skus: [] }); await Promise.all([first, second])
|
|
assert.equal(resolves, 1); assert.equal(canConfirmDuringLoad, false)
|
|
assert.equal(vm.discardTask.palletNo, 'NEW')
|
|
})
|
|
|
|
test('discard confirmation is guarded against double submission', async () => {
|
|
const pending = deferred(); let submits = 0
|
|
const { vm } = page({ confirmCargoDiscard: async () => { submits++; await pending.promise }, listCargoDiscardTasks: async () => [] })
|
|
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
|
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]; const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
|
|
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
|
|
})
|
|
|
|
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
|
|
const { vm } = page(); const calls = []
|
|
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
|
|
vm.resolveDiscardPallet = async code => calls.push(['discard', code]); vm.openTask = async code => calls.push(['outbound', code])
|
|
await vm.retry(); assert.deepEqual(calls, [['discard', 'PALLET']])
|
|
})
|
|
|
|
test('closing or switching discard order ignores the previous resolve response', async () => {
|
|
const pending = deferred()
|
|
const { vm } = page({ resolveCargoDiscardPallet: () => pending.promise })
|
|
vm.discardCurrent = { discardNo: 'FIRST' }
|
|
const first = vm.resolveDiscardPallet('PALLET')
|
|
vm.openDiscardOrder('SECOND')
|
|
pending.resolve({ discardNo: 'FIRST', palletNo: 'PALLET', skus: [] }); await first
|
|
assert.equal(vm.discardTask, null); assert.equal(vm.error, null)
|
|
})
|
|
|
|
|
|
test('loose disposal opens all marks, blocks partial scans and accepts explicit bulk confirmation', async () => {
|
|
const { vm } = page()
|
|
vm.discardTasks = [{ discardNo: 'CD-L', palletNo: '散箱', scanMode: 'CARTON', cartonMarks: ['BOX-1', 'BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }]
|
|
vm.openDiscardOrder('CD-L')
|
|
await Promise.resolve()
|
|
assert.equal(vm.discardTask.scanMode, 'CARTON')
|
|
assert.deepEqual(vm.discardTask.cartonMarks, ['BOX-1', 'BOX-2'])
|
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
|
assert.equal(vm.canConfirmDiscard, false)
|
|
await vm.resolveDiscardPallet('BOX-1'); await vm.resolveDiscardPallet('BOX-1')
|
|
assert.deepEqual(vm.discardScannedMarks, ['BOX-1'])
|
|
assert.equal(vm.canConfirmDiscard, false)
|
|
await vm.resolveDiscardPallet('BOX-2')
|
|
assert.equal(vm.canConfirmDiscard, true)
|
|
vm.discardActuals.sku = 1; assert.equal(vm.canConfirmDiscard, false)
|
|
vm.discardActuals.sku = 2; vm.discardScannedMarks = []; vm.discardAllCartons = true
|
|
assert.equal(vm.canConfirmDiscard, true)
|
|
vm.discardPhotos = []; assert.equal(vm.canConfirmDiscard, false)
|
|
})
|
|
|
|
|
|
test('partial bound pallet confirmation sends only selected marks and preserves all photo gates', async () => {
|
|
let payload
|
|
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
|
|
vm.discardTasks = [{ discardNo: 'CD-P', outboundNo: 'OB-P', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
|
|
vm.openDiscardOrder('CD-P'); await Promise.resolve()
|
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
|
await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true)
|
|
await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1')
|
|
await vm.confirmDiscard()
|
|
assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false])
|
|
})
|