diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue index a15651c..a913850 100644 --- a/src/pages/dispatch/dispatch.vue +++ b/src/pages/dispatch/dispatch.vue @@ -81,6 +81,15 @@ {{ successMessage }} + + + 托盘扫描成功 + {{ scanNotice.palletNo }} + 本次可拣箱数 + {{ scanNotice.packages }} 箱 + 3 秒后自动关闭 + + 确认装车 @@ -103,7 +112,7 @@ import { toDispatchError } from '@/utils/dispatch-error.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' export default { components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments }, - data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } }, + data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', scanNotice: null, loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } }, computed: { busy() { return this.requestBusy || this.photoBusy }, recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages }, @@ -121,9 +130,15 @@ export default { }, onLoad(options) { this.resumeOnEntry = options?.resume === '1' }, onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() }, - onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ }, + onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, methods: { - selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab }, + clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null }, + showScanNotice(item) { + this.clearScanNotice() + this.scanNotice = { palletNo: item.palletNo, packages: this.selectedRemaining } + this._scanNoticeTimer = setTimeout(() => this.clearScanNotice(), 3000) + }, + selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.clearScanNotice(); this.activeTab = tab }, gate(action) { return this.current?.actions?.[action]?.allowed === true }, startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null }, updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) }, @@ -133,6 +148,7 @@ export default { handleScan(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim() if (!code || this.busy || this.loading || this.activeTab !== 'picking') return + this.clearScanNotice() this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = '' updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }) if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code) @@ -151,6 +167,7 @@ export default { this.selectPallet(item) if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 }) + else this.showScanNotice(item) this.scanCode = ''; playSuccessSound() }, pickablePackages(item) { @@ -206,8 +223,8 @@ export default { }) }, async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() }, - async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } }, - setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() }, + async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } }, + setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound() }, stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' }, reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') }, reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' }, @@ -273,5 +290,11 @@ export default { .loading-cancel { color: #52657d; background: #f0f3f8; } .loading-confirm { color: #fff; background: #1764ec; } .loading-dialog-actions button[disabled] { opacity: .5; } +.scan-notice-overlay { pointer-events: none; } +.scan-notice-dialog { text-align: center; } +.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; } +.scan-notice-label { display: block; margin-top: 28rpx; color: #536982; font-size: 28rpx; } +.scan-notice-count { display: block; margin-top: 12rpx; color: #1764ec; font-size: 72rpx; font-weight: 700; line-height: 1.3; } +.scan-notice-hint { display: block; margin-top: 24rpx; color: #72839b; font-size: 24rpx; } diff --git a/tests/dispatch-workflow.test.mjs b/tests/dispatch-workflow.test.mjs index acb6b73..76482e8 100644 --- a/tests/dispatch-workflow.test.mjs +++ b/tests/dispatch-workflow.test.mjs @@ -43,6 +43,43 @@ function page(dependencies = {}) { } const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } }) +test('pallet scan notice reports the current pickable quantity and closes after three seconds without submitting', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + storage(); let writes = 0, sounds = 0 + const state = page({ pickDispatchItem: async () => { writes++ }, playSuccessSound: () => { sounds++ } }) + state.current = task({ items: [item({ scheduledPackages: 12, pickedPackages: 4, positions: [{ positionId: 'position-1', availablePackages: 6, allocatedPackages: 0, pickedPackages: 0 }] })] }) + state.handleScan('PALLET-1') + assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-1', packages: 6 }) + assert.equal(sounds, 1) + t.mock.timers.tick(2999); assert.ok(state.scanNotice) + t.mock.timers.tick(1); assert.equal(state.scanNotice, null) + assert.equal(writes, 0); assert.equal(state.packages, 6) +}) + +test('consecutive pallet scans replace the notice and restart its full display time', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + storage(); const state = page() + state.current = task({ items: [item(), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', palletNo: 'PALLET-2', scheduledPackages: 1 })] }) + state.handleScan('PALLET-1'); t.mock.timers.tick(2000) + state.handleScan('PALLET-2') + assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-2', packages: 1 }) + t.mock.timers.tick(1000); assert.ok(state.scanNotice) + t.mock.timers.tick(2000); assert.equal(state.scanNotice, null) +}) + +test('scan errors, carton scans and switching tabs cannot retain a stale pallet notice', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + storage(); const state = page() + state.handleScan('PALLET-1'); state.handleScan('UNKNOWN') + assert.equal(state.scanNotice, null); assert.ok(state.error) + state.handleScan('PALLET-1'); state.selectTab('photos') + assert.equal(state.scanNotice, null) + state.selectTab('picking'); state.handleScan('PALLET-1') + state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] }) + state.handleScan('RC-1'); assert.equal(state.scanNotice, null) + t.mock.timers.tick(3000); assert.equal(state.scanNotice, null) +}) + test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => { storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true) for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }