diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js index 82cb71a..2aa4a04 100644 --- a/src/api/fulfillment.js +++ b/src/api/fulfillment.js @@ -153,7 +153,10 @@ export function normalizeCargoDiscardTask(value) { return { ...task, discardNo: String(task?.discardNo || ''), + outboundNo: String(task?.outboundNo || ''), palletNo: String(task?.palletNo || ''), + scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET', + cartonMarks: asList(task?.cartonMarks).map(String), status: String(task?.status || 'PENDING'), skus: asList(task?.skus).map((sku) => ({ ...sku, @@ -171,12 +174,12 @@ export async function listCargoDiscardTasks() { return asList(await overseasRequest('GET', CARGO_DISCARD_BASE)).map(normalizeCargoDiscardTask) } -export async function resolveCargoDiscardPallet(palletNo) { - return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo })) +export async function resolveCargoDiscardPallet(palletNo, discardNo) { + return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo, ...(discardNo ? { discardNo } : {}) })) } -export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds) { - return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds })) +export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false) { + return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds, scannedCartonMarks, confirmAllCartons })) } const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files` diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue index 60097e7..65cdc72 100644 --- a/src/pages/outbound/outbound.vue +++ b/src/pages/outbound/outbound.vue @@ -7,7 +7,7 @@ 当前任务出库作业详情 - + 任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。 @@ -20,7 +20,7 @@ 暂无出库任务可刷新列表,或扫描任务号恢复未完成作业。 - 废弃订单{{ discardOrderCount }} 个订单 + 废弃订单{{ discardOrderCount }} 个订单 {{ successMessage }} @@ -66,7 +66,7 @@ export default { components: { PortalHeader, ScanTargetInput }, - data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } }, + data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardScannedMarks: [], discardAllCartons: false, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } }, computed: { displayItems() { const items = this.current?.items || [] @@ -88,8 +88,8 @@ return rows }, hasReturnPallet() { return this.current?.outboundType === 'RETURN_OUTBOUND' && (this.current.items || []).some(item => item.palletNo) }, - scanLabel() { return this.discardCurrent ? '废弃订单托盘码' : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' }, - scanPlaceholder() { return this.discardCurrent ? '扫描废弃订单对应托盘码' : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' }, + scanLabel() { return this.discardCurrent ? (this.discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃订单托盘码') : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' }, + scanPlaceholder() { return this.discardCurrent ? (this.discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描废弃订单对应托盘码') : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' }, recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' }, packages() { return fulfillment.outbound.packages }, plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) }, @@ -99,9 +99,9 @@ taskOperable() { return ['PENDING', 'IN_PROGRESS'].includes(this.current?.status) }, canPick() { return this.taskOperable && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining }, canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable }, - discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] }, + discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] }, discardOrderCount() { return this.discardOrders.length }, - canConfirmDiscard() { return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) } + canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && task.skus.length && task.skus.every(sku => Number(this.discardActuals[sku.itemId]) === sku.plannedPackages) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && (this.discardAllCartons || task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))))) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() }, onHide() { this.stopScanner() }, @@ -129,19 +129,22 @@ }, true) }, closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() }, - openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null }, + openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardScannedMarks = []; this.discardAllCartons = false; this.error = null; const loose = this.discardTasks.find(task => task.discardNo === code && task.scanMode === 'CARTON' && task.status !== 'COMPLETED'); if (loose) { this.discardTask = normalizeCargoDiscardTask(loose); this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => [sku.itemId, sku.plannedPackages])); this.restoreDiscardPhotos() } }, closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() }, updateDiscardPalletCode(value) { this.discardPalletCode = value }, async resolveDiscardPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim() if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return + if (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) { if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]; this.error = null; playSuccessSound(); return } + if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && task.scanMode !== 'CARTON' && task.palletNo === code)) { this.setError(new Error('箱唛不属于当前废弃作业'), '箱唛核对失败'); return } const order = this.discardCurrent - this.resetDiscardPhotos(); this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null + this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardAllCartons = false; this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null try { - const task = await resolveCargoDiscardPallet(code) + const task = await resolveCargoDiscardPallet(code, order.discardNo) if (this.discardCurrent !== order) return if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单') this.discardTask = normalizeCargoDiscardTask(task) + if (this.discardTask.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) this.discardScannedMarks = [code] this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])); await this.restoreDiscardPhotos() } catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') } finally { if (this.discardCurrent === order) this.discardLoading = false } @@ -181,11 +184,13 @@ }, fail: () => { this.discardPhotoBusy = false } }) }, updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: value } }, - async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId))); this.successMessage = '废弃出库作业及照片已提交,等待仓库确认'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } }, + async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: sku.plannedPackages })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId)), this.discardScannedMarks, this.discardAllCartons); this.successMessage = '废弃出库作业及照片已提交,已完成'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } }, handleScan(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code }) if (this.discardCurrent) return this.resolveDiscardPallet(code) + const discardOrder = !this.current && this.discardOrders.find(order => order.discardNo === code || order.outboundNo === code) + if (discardOrder) return this.openDiscardOrder(discardOrder.discardNo) if (!this.current) return this.openScannedTask(code) if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo) if (!this.taskOperable) return diff --git a/tests/outbound-lifecycle.test.mjs b/tests/outbound-lifecycle.test.mjs index 06c0425..787fee3 100644 --- a/tests/outbound-lifecycle.test.mjs +++ b/tests/outbound-lifecycle.test.mjs @@ -18,9 +18,29 @@ function page(overrides = {}) { 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('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('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: 1 } + 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) @@ -129,3 +149,37 @@ test('closing or switching discard order ignores the previous resolve response', 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]) +})