Files
pda-overseas-uniapp/tests/outbound-lifecycle.test.mjs
2026-10-29 15:50:06 +08:00

464 lines
29 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, resolveCargoDiscardPallet: async code => vm.discardTasks.find(task => task.palletNo === code || task.cartonMarks?.includes(code)),
...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-1')
await vm.resolveDiscardPallet('P-2')
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', 'P-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, false)
vm.discardActuals = { sku: 0 }; for (let i=0;i<6;i++) await vm.scanDiscardCarton('FIELD-'+i)
await vm.scanDiscardCarton('FIELD-0'); assert.equal(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, scannedCartonMarks: Array.from({ length: 6 }, (_, i) => 'FIELD-'+i) }])
})
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, 0)
assert.equal(vm.canConfirmDiscard, false)
})
test('无关联任务先扫托盘,再扫现场箱唛自动计数', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-NO-BOX', palletNo: 'P-1', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 2, actualQuantityEditable: true }] }]
vm.openDiscardOrder('CD-NO-BOX'); await Promise.resolve()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.discardTask.palletNo, 'P-1')
assert.equal(vm.discardActuals.sku, 0)
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B')
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.canConfirmDiscard, true)
})
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 and requires every carton scan', 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')
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('BOX-1')
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, false)
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()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1')
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.discardTasks = [{ ...vm.discardTask, discardNo: 'CD-RETRY' }]
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, /已完成/)
})
test('dedicated scanner validates carton scope and preserves photos', () => {
const { vm } = page()
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BX-1', 'BX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }
vm.discardActuals = { sku: 2 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
assert.deepEqual(vm.discardPendingMarks, ['BX-1', 'BX-2'])
assert.deepEqual(vm.discardQuantityLines, [])
vm.discardCartonCode = ' BX-1 '; vm.scanDiscardCarton()
assert.equal(vm.discardCartonCode, '')
vm.scanDiscardCarton('BX-1'); vm.scanDiscardCarton('OTHER')
assert.deepEqual(vm.discardScannedMarks, ['BX-1']); assert.equal(vm.canConfirmDiscard, false)
assert.deepEqual(vm.discardPendingMarks, ['BX-2'])
assert.match(vm.error.message, /箱号不属于/)
vm.discardCartonsExpanded = false
vm.scanDiscardCarton('BX-2'); assert.equal(vm.canConfirmDiscard, true)
assert.deepEqual(vm.discardPendingMarks, [])
assert.equal(vm.discardCartonsExpanded, false)
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }]); assert.equal(vm.discardTask.palletNo, 'P-1')
vm.discardTask.status = 'COMPLETED'; vm.scanDiscardCarton('OTHER'); assert.equal(vm.error, null)
})
test('bound discard opens only after its pallet scan and summary hides carton numbers', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
vm.openDiscardOrder('CD-1'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('BX-1'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('OTHER'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1'); assert.equal(vm.discardTask.palletNo, 'P-1')
assert.deepEqual(vm.discardScannedMarks, [])
assert.deepEqual(vm.discardTask.cartonMarks, ['BX-1'])
const template = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8')
const summary = template.slice(template.indexOf('废弃箱关联托盘'), template.indexOf('<ScanTargetInput name="cargo-discard"'))
assert.doesNotMatch(summary, /task.cartonMarks/)
})
test('multi-pallet discard prompts remaining pallet numbers and requires a new pallet scan', async () => {
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku1', plannedPackages: 1 }] }
const second = { ...first, palletNo: 'P-2', cartonMarks: ['BX-2'], skus: [{ itemId: 'sku2', plannedPackages: 1 }] }
const other = { ...first, discardNo: 'CD-OTHER', palletNo: 'P-OTHER' }
let completed = 0
const { vm } = page({ confirmCargoDiscard: async () => { completed++ }, listCargoDiscardTasks: async () => [{ ...first, status: 'COMPLETED' }, { ...second, status: completed === 2 ? 'COMPLETED' : 'PENDING' }, other] })
vm.discardTasks = [first, second, other]; vm.openDiscardOrder('CD-M')
await vm.resolveDiscardPallet('P-1'); vm.scanDiscardCarton('BX-1')
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.equal(vm.discardTask, null); assert.equal(vm.discardPalletCode, '')
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
assert.match(vm.successMessage, /P-1.*已完成.*P-2.*继续扫描/)
assert.doesNotMatch(vm.successMessage, /P-OTHER|全部/)
assert.equal(vm.canConfirmDiscard, false)
await vm.resolveDiscardPallet('P-2'); assert.equal(vm.successMessage, '')
assert.deepEqual(vm.discardPendingMarks, ['BX-2']); assert.deepEqual(vm.discardPhotos, [])
vm.scanDiscardCarton('BX-2'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '102' }]
await vm.confirmDiscard()
assert.deepEqual(vm.discardPendingTasks, []); assert.match(vm.successMessage, /本单全部.*已完成/)
})
test('successful discard followed by refresh failure does not claim the whole order completed', async () => {
let confirms = 0
const { vm } = page({ confirmCargoDiscard: async () => { confirms++ }, listCargoDiscardTasks: async () => { throw new Error('offline') } })
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardTasks = [first, { ...first, palletNo: 'P-2' }]; vm.openDiscardOrder('CD-M')
await vm.resolveDiscardPallet('P-1'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(confirms, 1); assert.equal(vm.discardTask, null)
assert.match(vm.successMessage, /P-1.*已完成.*刷新失败/); assert.doesNotMatch(vm.successMessage, /全部/)
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
})
test('仓租余额不足显示原因并保留当前托盘、已扫箱号、实际数与现场照片', async () => {
const { vm } = page({ confirmCargoDiscard: async () => { throw Object.assign(new Error('仓租校验未通过:可用余额和授信额度不足'), { code: 'A0409' }) } })
vm.discardCurrent = { discardNo: 'CD-1' }
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', status: 'PENDING', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.match(vm.error.message, /余额和授信额度不足/); assert.equal(vm.error.code, 'A0409')
assert.equal(vm.discardTask.palletNo, 'P-1'); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
assert.equal(vm.successMessage, ''); assert.equal(vm.discardBusy, false)
})
for (const action of ['pallet', 'carton', 'retry']) {
test(`出库资金不足后通过 ${action} 重新核对并再次手动提交`, async () => {
let submits = 0; let reads = 0; let key = 0; const keys = []
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
const { vm } = page({
resolveCargoDiscardPallet: async () => { reads++; return { ...task } },
confirmCargoDiscard: async (...args) => { keys.push(args.at(-1).idempotencyKey); if (++submits === 1) throw new Error('可用余额和授信额度不足') },
fulfillmentIdempotencyKey: () => ({ value: `key-${++key}` }), listCargoDiscardTasks: async () => []
})
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task; vm.discardTasks = [task]
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
if (action === 'pallet') await vm.resolveDiscardPallet('P-1')
else if (action === 'carton') await vm.scanDiscardCarton('BX-1')
else await vm.retry()
assert.equal(reads, 1); assert.equal(submits, 1); assert.equal(vm.error, null)
assert.equal(vm.discardNeedsRefresh, false); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
await vm.confirmDiscard(); assert.equal(submits, 2); assert.notEqual(keys[0], keys[1])
assert.match(vm.successMessage, /全部.*已完成/)
})
}
test('结果不明后刷新发现已完成,不再重复出库', async () => {
let submits = 0
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw Object.assign(new Error('网络中断'), { network: true }) }, resolveCargoDiscardPallet: async () => ({ ...task, status: 'COMPLETED' }) })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(submits, 1); assert.equal(vm.discardTask.status, 'COMPLETED'); assert.equal(vm.canConfirmDiscard, false)
})
test('失败后刷新仍报错,保留草稿且不再发送出库', async () => {
let submits = 0
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw new Error('出库失败') }, resolveCargoDiscardPallet: async () => { throw new Error('任务读取失败') } })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(submits, 1); assert.match(vm.error.message, /任务读取失败/)
assert.equal(vm.discardNeedsRefresh, true); assert.equal(vm.discardBusy, false); assert.equal(vm.discardLoading, false)
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
})
test('无关联扫码显示备注,不能扫托盘或超过目标,失败刷新后保留现场箱唛再提交', async () => {
let attempts = 0
const task = { discardNo: 'CD-FIELD', palletNo: 'PALLET-FIELD', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remark: '只废弃破损外箱', remainingPackages: 2, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
const { vm } = page({ confirmCargoDiscard: async () => { if (++attempts === 1) throw new Error('余额不足') }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
assert.equal(vm.discardOrderRemark, '只废弃破损外箱')
await vm.scanDiscardCarton(task.palletNo); assert.equal(vm.discardActuals.sku, 0)
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B'); await vm.scanDiscardCarton('FIELD-C')
assert.equal(vm.discardActuals.sku, 2); assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['FIELD-A', 'FIELD-B'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
await vm.resolveDiscardPallet(task.palletNo)
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.discardUnboundScans.length, 2)
assert.equal(vm.discardPhotos.length, 1); await vm.confirmDiscard(); assert.equal(attempts, 2)
})
test('已扫描现场箱唛可移除并同步回减数量,确认只提交剩余箱唛', async () => {
let payload
const task = { discardNo: 'CD-REMOVE', palletNo: 'P-REMOVE', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remainingPackages: 4, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
await vm.scanDiscardCarton('BOX-1'); await vm.scanDiscardCarton('BOX-2')
vm.removeDiscardScan('BOX-1')
assert.equal(vm.discardActuals.sku, 1)
assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['BOX-2'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 1, scannedCartonMarks: ['BOX-2'] }])
})