66 lines
9.6 KiB
Vue
66 lines
9.6 KiB
Vue
<template>
|
||
<view v-if="user" class="page-shell portal-page work-page">
|
||
<PortalHeader title="商品移库" :user="user" />
|
||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||
<view class="work-heading"><text class="work-title">整托移库</text></view>
|
||
<view v-if="restoring" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在恢复未完成的移库草稿…</text></view>
|
||
<view v-else-if="!source && !sourceCode" class="empty-box"><text class="empty-copy">暂无已读取托盘,请先扫描或输入来源托盘码</text></view>
|
||
<view class="work-card">
|
||
<text class="card-title">1. 来源托盘</text>
|
||
<ScanTargetInput name="source" v-model="sourceCode" label="托盘码" placeholder="扫描或输入来源托盘码" :active="activeTarget === 'source'" @activate="activeTarget = 'source'" @submit="resolveSource" />
|
||
<button class="secondary" :disabled="!sourceCode.trim() || busy" @tap="resolveSource(sourceCode)">{{ resolving === 'source' ? '查询中…' : '读取托盘状态' }}</button>
|
||
<view v-if="source" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ source.palletCode }}</text><text class="meta-line">当前位置:{{ source.locationCode || '未绑定库位' }}</text></view><text class="status-pill">{{ statusLabel(source.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ source.occupiedPackages }}</strong><text>在托箱数</text></view><view class="metric"><strong>{{ source.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view>
|
||
</view>
|
||
<view class="work-card">
|
||
<text class="card-title">2. 目标库位</text>
|
||
<ScanTargetInput name="target" v-model="targetCode" label="库位码" placeholder="扫描或输入目标库位码" :active="activeTarget === 'target'" @activate="activeTarget = 'target'" @submit="resolveTarget" />
|
||
<button class="secondary" :disabled="!targetCode.trim() || busy" @tap="resolveTarget(targetCode)">{{ resolving === 'target' ? '校验中…' : '校验目标库位' }}</button>
|
||
<view v-if="target" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ target.locationCode }}</text><text class="meta-line">{{ locationTypeLabel(target.locationType) }}</text></view><text class="status-pill">{{ statusLabel(target.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ target.occupiedPackages }}</strong><text>已占箱数</text></view><view class="metric"><strong>{{ target.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view>
|
||
</view>
|
||
<view class="work-card"><text class="card-title">3. 移库原因(必填)</text><textarea v-model="reason" class="reason-input" maxlength="500" aria-label="移库原因(必填)" /><button class="primary" :disabled="!canMove || busy" @tap="submitMove">{{ busyAction === 'move' ? '移库中…' : '确认整托移库' }}</button><text class="hint">发生冲突后请重新扫描。</text></view>
|
||
<view v-if="result" class="success-box" role="status"><text class="success-title">移库完成</text><text>{{ result.moveNo || '移库单号待生成' }}</text><text>{{ result.palletCode }}:{{ result.sourceLocationCode || '未绑定库位' }} → {{ result.targetLocationCode || '—' }}</text><text>{{ result.packageCount }} 箱 · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}</text><button class="secondary" @tap="resetPage">继续移库</button></view>
|
||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button class="secondary" @tap="refreshFacts">重新读取托盘和库位</button></view>
|
||
</view></scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import PortalHeader from '@/components/PortalHeader.vue'
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
|
||
import { session, requireSession } from '@/stores/session.js'
|
||
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||
|
||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||
export default {
|
||
components: { PortalHeader, ScanTargetInput },
|
||
data() { return { user: session.currentUser, source: null, target: null, activeTarget: 'source', resolving: '', restoring: false, busy: false, busyAction: '', error: null, result: null } },
|
||
computed: {
|
||
sourceCode: { get() { return warehouseWork.move.sourcePalletCode }, set(value) { updateWarehouseWork('move', { sourcePalletCode: value }); if (this.source?.palletCode !== String(value || '').trim()) this.source = null } },
|
||
targetCode: { get() { return warehouseWork.move.targetLocationCode }, set(value) { updateWarehouseWork('move', { targetLocationCode: value }); if (this.target?.locationCode !== String(value || '').trim()) this.target = null } },
|
||
reason: { get() { return warehouseWork.move.reason }, set(value) { updateWarehouseWork('move', { reason: value }) } },
|
||
canMove() { return Boolean(this.source && this.target && this.reason.trim() && this.source.locationCode !== this.target.locationCode) }
|
||
},
|
||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.restoreDraftFacts() },
|
||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||
methods: {
|
||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||
handleScan(value) { if (this.activeTarget === 'target') { this.targetCode = value; this.resolveTarget(value) } else { this.sourceCode = value; this.resolveSource(value) } },
|
||
async restoreDraftFacts() { this.restoring = true; try { if (this.sourceCode) await this.resolveSource(this.sourceCode, false); if (this.targetCode) await this.resolveTarget(this.targetCode, false) } finally { this.restoring = false } },
|
||
async resolveSource(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.sourceCode).trim(); if (!code || this.busy) return; this.sourceCode = code; this.resolving = 'source'; this.error = null; try { this.source = await resolveWarehousePallet(code); this.sourceCode = this.source.palletCode; if (sound) playSuccessSound(); this.activeTarget = 'target' } catch (error) { this.setError(error, '来源托盘查询失败') } finally { this.resolving = '' } },
|
||
async resolveTarget(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.targetCode).trim(); if (!code || this.busy) return; this.targetCode = code; this.resolving = 'target'; this.error = null; try { this.target = await resolveWarehouseLocation(code); this.targetCode = this.target.locationCode; if (sound) playSuccessSound() } catch (error) { this.setError(error, '目标库位校验失败') } finally { this.resolving = '' } },
|
||
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
|
||
async refreshFacts(clearError = true) { const retainedError = clearError ? null : this.error; const sourceCode = this.sourceCode; const targetCode = this.targetCode; this.source = null; this.target = null; try { if (sourceCode) this.source = await resolveWarehousePallet(sourceCode); if (targetCode) this.target = await resolveWarehouseLocation(targetCode); this.error = retainedError } catch (error) { this.setError(error, '最新托盘或库位状态读取失败') } },
|
||
resetPage() { resetWarehouseDomain('move'); this.source = null; this.target = null; this.result = null; this.error = null; this.activeTarget = 'source' },
|
||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||
statusLabel(value) { return ({ ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', LOCKED: '已锁定' })[value] || value || '状态未知' },
|
||
locationTypeLabel(value) { return ({ STORAGE: '存储库位', STAGING: '暂存库位', RELABEL: '换标库位', RECEIVING: '收货库位' })[value] || value || '库位类型未标注' }
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>
|