Files
pda-overseas-uniapp/tests/outbound-lifecycle.test.mjs
T

286 lines
16 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' }),
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('无精确箱唛对应的托盘必须填写实际数并按实点提交,不能超量或用空值代替零', async () => {
let payload
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardCurrent = { discardNo: 'CD-A' }
vm.discardTask = { palletNo: 'P-A', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
for (const value of ['', ' ', null, -1, 11, 1.5, 'abc']) {
vm.discardActuals = { sku: value }; assert.equal(vm.canConfirmDiscard, false)
}
vm.discardActuals = { sku: '0' }; assert.equal(vm.canConfirmDiscard, true)
vm.discardActuals = { sku: '6' }; assert.equal(vm.canConfirmDiscard, true)
await vm.confirmDiscard()
assert.equal(payload[0], 'CD-A'); assert.equal(payload[1], 'P-A')
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 6 }])
})
test('无箱唛对应托盘扫码后实际数保持未填,必须由仓库实点填写', async () => {
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }) })
vm.discardCurrent = { discardNo: 'CD-A' }
await vm.resolveDiscardPallet('P-A')
assert.equal(vm.discardActuals.sku, '')
assert.equal(vm.canConfirmDiscard, false)
})
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, { idempotencyKey: 'key' }])
})
test('discard confirmation preserves inputs after stock command failure and starts a new explicit 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 },
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(vm.discardBusy, false)
assert.ok(vm.discardTask)
assert.equal(vm.error.message, '库存状态已变化')
vm.successMessage = '上次作业已完成'
const retried = vm.confirmDiscard()
assert.equal(vm.error, null)
assert.equal(vm.successMessage, '')
retry.resolve(); await retried
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, /已完成/)
})