feat: complete overseas PDA warehouse operations
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="库存盘点" :user="user" fallback-url="/pages/module/module?key=inventory" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">库存盘点</text><text class="work-subtitle">按 Token 绑定的海外仓冻结库位或托盘库存,逐位置实盘后提交差异</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" placeholder="必填,例如:月度循环盘点" /><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 { releaseWarehouseKey, 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 }); releaseWarehouseKey('stocktake', key.signature); 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 }); releaseWarehouseKey('stocktake', key.signature); 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 }); releaseWarehouseKey('stocktake', key.signature); 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)) { if (key) releaseWarehouseKey('stocktake', key.signature); 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>
|
||||
Reference in New Issue
Block a user