Files
pda-overseas-uniapp/src/pages/stocktake/stocktake.vue
T

81 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 class="work-card">
<text class="card-title">新建盘点任务</text>
<view class="mode-row"><button class="mode-button" :class="{ active: scopeType === 'LOCATION' }" @tap="changeScope('LOCATION')">按库位</button><button class="mode-button" :class="{ active: scopeType === 'PALLET' }" @tap="changeScope('PALLET')">按托盘</button></view>
<ScanTargetInput name="scope" v-model="scopeCode" :label="scopeType === 'LOCATION' ? '库位码' : '托盘码'" :placeholder="scopeType === 'LOCATION' ? '扫描或输入库位码' : '扫描或输入托盘码'" :active="true" @submit="createTask" />
<button class="primary" :disabled="!scopeCode.trim() || busy" @tap="createTask(scopeCode)">{{ busyAction === 'create' ? '创建中…' : '创建并冻结盘点范围' }}</button>
</view>
<view class="row-head"><text class="card-title">进行中的任务</text><button class="link-button" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中…' : '刷新' }}</button></view>
<view v-if="loading && !tasks.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载盘点任务…</text></view>
<view v-else-if="!tasks.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">暂无进行中的盘点任务</text></view>
<view v-else class="task-list"><view v-for="task in tasks" :key="task.taskNo" class="task-card" :class="{ current: current?.task?.taskNo === task.taskNo }" @tap="openTask(task.taskNo)"><view class="row-head"><view><text class="business-code">{{ task.taskNo }}</text><text class="meta-line">{{ scopeLabel(task.scopeType) }} · {{ task.scopeCode || '—' }}</text></view><text class="status-pill">{{ statusLabel(task.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ task.positionCount }}</strong><text>位置</text></view><view class="metric"><strong>{{ task.countedCount }}</strong><text>已盘</text></view><view class="metric"><strong>{{ task.differenceCount }}</strong><text>差异</text></view><text class="link-button">继续 ›</text></view></view></view>
<view v-if="current" class="work-card">
<view class="row-head"><view><text class="card-title">当前任务</text><text class="business-code">{{ current.task.taskNo }}</text><text class="meta-line">{{ scopeLabel(current.task.scopeType) }} · {{ current.task.scopeCode || '—' }}</text></view><text class="status-pill" :class="{ good: current.task.status === 'SUBMITTED' }">{{ statusLabel(current.task.status) }}</text></view>
<view v-if="!current.items.length" class="empty-box"><text class="empty-copy">该任务暂无库存位置</text></view>
<view v-else class="item-list section-gap"><view v-for="item in current.items" :key="item.itemId" class="item-card" :class="{ done: item.actualPackages != null }">
<view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productName || '商品名称未关联' }}</text><text class="meta-line">{{ item.inboundCode || '无入仓码' }} · {{ item.palletCode || '未绑定托盘' }} · {{ item.locationCode || '未绑定库位' }}</text></view><text class="status-pill" :class="{ good: item.actualPackages != null }">{{ item.actualPackages == null ? '待盘' : '已盘' }}</text></view>
<view class="item-grid"><view><strong>{{ item.bookPackages }}</strong><text>账面箱数</text></view><view><strong>{{ item.actualPackages == null ? '—' : item.actualPackages }}</strong><text>实盘箱数</text></view><view><strong>{{ item.differencePackages == null ? '—' : item.differencePackages }}</strong><text>差异</text></view></view>
<input v-model.number="item.inputPackages" class="number-input section-gap" type="number" inputmode="numeric" placeholder="输入实盘箱数(可为 0)" />
<button class="secondary" :disabled="busy || !validCount(item) || current.task.status !== 'IN_PROGRESS'" @tap="saveCount(item)">{{ isSaving(item) ? '保存中…' : '保存本位置实盘数' }}</button>
</view></view>
<template v-if="current.task.status === 'IN_PROGRESS'"><text class="card-title section-gap">提交原因(必填)</text><textarea v-model="reason" class="reason-input" maxlength="500" aria-label="提交原因(必填)" /><button class="primary" :disabled="busy || !allCounted || !reason.trim()" @tap="submitTask">{{ busyAction === 'submit' ? '提交中…' : '提交盘点并调整差异' }}</button><text class="hint">所有位置完成实盘后才能提交。</text></template>
</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="retry">重新读取</button></view>
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
</view></scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { countStocktake, createStocktake, getStocktake, listStocktakes, submitStocktake } from '@/api/warehouse-operations.js'
import { session, requireSession } from '@/stores/session.js'
import { 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, tasks: [], current: null, loading: false, busy: false, busyAction: '', busyItem: '', error: null, successMessage: '' } },
computed: {
scopeType: { get() { return warehouseWork.stocktake.scopeType }, set(value) { updateWarehouseWork('stocktake', { scopeType: value }) } },
scopeCode: { get() { return warehouseWork.stocktake.scopeCode }, set(value) { updateWarehouseWork('stocktake', { scopeCode: value }) } },
reason: { get() { return warehouseWork.stocktake.reason }, set(value) { updateWarehouseWork('stocktake', { reason: value }) } },
allCounted() { return Boolean(this.current?.items?.length) && this.current.items.every((item) => item.actualPackages != null) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
isSaving(item) { return this.busyItem === item.positionId },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { this.scopeCode = value; this.createTask(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async bootstrap() { await this.loadTasks(); const saved = warehouseWork.stocktake.taskNo; if (saved) await this.openTask(saved, false) },
async loadTasks() { if (this.loading) return; this.loading = true; this.error = null; try { this.tasks = await listStocktakes('IN_PROGRESS') } catch (error) { this.setError(error, '盘点任务加载失败') } finally { this.loading = false } },
changeScope(value) { this.scopeType = value; this.scopeCode = '' },
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
async openTask(taskNo, feedback = true) { if (!taskNo || this.busy) return; await this.run('detail', async () => { const value = await getStocktake(taskNo); this.applyCurrent(value); if (feedback) playSuccessSound() }, false) },
applyCurrent(value) { this.current = value; this.current.items = this.current.items.map((item) => ({ ...item, inputPackages: item.actualPackages })); updateWarehouseWork('stocktake', { taskNo: value.task.taskNo }) },
validCount(item) { const value = Number(item.inputPackages); return Number.isInteger(value) && value >= 0 },
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
async reloadCurrent() { if (this.current?.task?.taskNo) this.applyCurrent(await getStocktake(this.current.task.taskNo)) },
async retry() { this.error = null; if (this.current?.task?.taskNo) await this.openTask(this.current.task.taskNo, false); else await this.loadTasks() },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
scopeLabel(value) { return value === 'PALLET' ? '托盘盘点' : '库位盘点' },
statusLabel(value) { return ({ IN_PROGRESS: '进行中', SUBMITTED: '已提交' })[value] || value || '状态未知' }
}
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>