Files
pda-uniapp/pages/picking/picking.vue
T
2026-08-15 09:47:28 +08:00

1172 lines
61 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="currentUser" class="page-shell">
<view class="work-page" :aria-hidden="activeOverlay ? 'true' : 'false'">
<view class="app-header">
<view class="header-top">
<button class="header-back" aria-label="返回上一页" @tap="leavePicking">‹</button>
<text class="header-title">集装箱拣货</text>
<view class="header-actions">
<text class="header-badge">{{ currentUser.warehouse }}</text>
</view>
</view>
</view>
<view class="work-content">
<view class="box-select-wrap">
<button class="box-select-btn" @tap="toggleBoxDropdown">
<text>{{ currentBox.boxNo }}<template v-if="currentBox.currentContainerNo"> · 当前柜号 {{ currentBox.currentContainerNo }}</template> · 订单 {{ currentBox.orderNo }}</text>
<text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text>
</button>
<view v-if="boxDropdownOpen" class="box-dropdown">
<view
v-for="(box, index) in boxes"
:key="box.boxNo"
class="box-option"
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
@tap="requestBoxSwitch(index)"
>
<view><text class="box-option-number">{{ box.boxNo }}</text><text class="box-option-order">{{ box.orderNo }}</text></view>
<text v-if="completedBoxes[index]" class="box-option-state">已完成</text>
<text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text>
</view>
</view>
</view>
<view class="scan-section">
<view class="scan-row">
<ScanTargetInput v-model="scanValue" name="boxCode" label="箱码扫码框" placeholder="扫描或输入箱唛标签码" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
</view>
<text class="scan-hardware-tip">支持硬件广播扫码,也可点击输入后按“完成”确认</text>
<view v-if="lastScanResult" class="last-scan" :class="lastScanResult.type" role="status">
<view class="last-scan-icon">{{ lastScanResult.type === 'success' ? '✓' : '!' }}</view>
<view class="last-scan-content">
<text class="last-scan-label">最近扫码 · {{ lastScanResult.title }}</text>
<text class="last-scan-box-code">{{ lastScanResult.boxCode }}</text>
<text class="last-scan-detail">{{ lastScanResult.detail }}</text>
</view>
</view>
<view v-if="scanHistory.length > 1" class="scan-history">
<text v-for="entry in scanHistory.slice(1, 3)" :key="entry.id" class="scan-history-item">{{ entry.boxCode }} · {{ entry.title }}</text>
</view>
</view>
<view class="progress-section">
<view class="progress-header">
<text class="progress-title">拣货进度</text>
<view class="progress-actions">
<view class="progress-stats">
<template v-if="summary.partial > 0">
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
<text> · 部分拣 </text><text class="progress-number">{{ summary.partial }}</text>
<text> / {{ summary.total }} 箱</text>
</template>
<template v-else>
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
<text> / {{ summary.total }} 箱</text>
</template>
</view>
<button class="box-tasks-btn" @tap="openBoxList('pending')">箱任务 {{ completedCount }}/{{ boxes.length }}</button>
</view>
</view>
<view class="progress-bar-bg">
<view class="progress-bar-fill" :style="{ transform: 'scaleX(' + summary.progress / 100 + ')' }"></view>
</view>
</view>
<view class="pick-list">
<view v-if="orderedItems.length === 0" class="empty-state">
<text class="empty-state-icon">□</text>
<text class="empty-state-text">该箱号暂无拣货任务</text>
</view>
<view
v-for="item in orderedItems"
:key="item.id"
class="pick-item"
:class="itemState(item)"
>
<view class="pick-item-row1">
<text class="pick-item-sku">{{ item.inboundCode || item.orderNo || '箱级拣货明细' }}</text>
<view class="pick-item-meta">
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
部分拣 {{ partialQuantities[item.id] || 0 }}/{{ item.qty }}
</text>
<text v-else-if="itemState(item) === ITEM_STATUS.SKIPPED" class="pick-status-tag tag-skipped">已跳拣</text>
<view class="pick-item-qty">
<text>需拣 </text><text class="qty-number">{{ item.qty }}</text><text> 箱</text>
<template v-if="itemState(item) === ITEM_STATUS.PARTIAL">
<text> · 实拣 </text><text class="actual-number">{{ partialQuantities[item.id] || 0 }}</text>
</template>
</view>
</view>
</view>
<view class="pick-item-row2">
<text class="pick-item-desc">{{ itemDescription(item) }}</text>
<text class="pick-item-location">{{ item.location }}</text>
</view>
<view v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认拣出</button>
<button class="pick-btn pick-btn-partial" @tap="openPartial(item.id)">部分拣货</button>
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
</view>
<view v-else-if="!completedBoxes[currentBoxIndex]" class="pick-item-actions processed-actions">
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
</view>
</view>
</view>
</view>
<view v-if="currentBox.inboundId && pickingCompleted" class="loading-evidence-section">
<view class="section-heading-row">
<view><text class="section-title">装柜证据</text><text class="section-subtitle">空仓、半仓、满仓、锁仓及带锁码照</text></view>
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
</view>
<view class="evidence-grid">
<view v-for="slot in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntries(slot.type).length }">
<text class="evidence-label">{{ slot.label }}</text>
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-file-row">
<text class="evidence-file">{{ photo.fileName || '已记录' }}</text>
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-retry-btn" @tap="voidEvidencePhoto(photo)">作废</button>
</view>
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
{{ evidenceUploadState(slot.type) }}
</text>
<button class="evidence-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
<button v-if="evidenceQueueEntry(slot.type)?.status === 'FAILED'" class="evidence-retry-btn" @tap="retryEvidence(slot.type)">重试上传</button>
</view>
</view>
<view class="seal-row">
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
<button class="evidence-btn seal-btn" @tap="saveSealNo">保存封签</button>
</view>
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
<button class="loading-complete-btn" :disabled="submitting || loadingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<view v-if="loadingVersion === null" class="remote-error-row">
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
</view>
</view>
<view class="submit-section">
<view class="submit-info">
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 箱待拣</text></view>
<text class="submit-status-hint">{{ submitStatusHint }}</text>
</view>
<view v-if="remoteError && !pickingCompleted" class="remote-error-row submit-error-row">
<text class="remote-error" role="alert">{{ remoteError }}</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
</view>
<button class="submit-btn" aria-label="提交拣货完成" :disabled="submitting || completedBoxes[currentBoxIndex] || pickingCompleted || !summary.canSubmit" @tap="submitPicking">
{{ submitting ? '正在提交…' : submitButtonText }}
</button>
</view>
</view>
<uni-popup ref="boxListPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('boxList', $event)">
<view class="modal-sheet box-list-modal" role="dialog" aria-modal="true" :aria-label="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @tap.stop>
<PopupHeader :title="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @close="closeOverlay" />
<view v-if="visibleBoxList.length === 0" class="modal-empty">
{{ boxListType === 'done' ? '暂无已拣完成的集装箱' : '所有集装箱已拣完成' }}
</view>
<view
v-for="entry in visibleBoxList"
:key="entry.box.boxNo"
class="box-list-item"
:class="boxListType === 'done' ? 'box-list-item-done' : 'box-list-item-pending'"
@tap="selectBoxFromList(entry)"
>
<view class="box-list-info">
<text class="box-list-number">{{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text>
<text class="box-list-detail">{{ boxListDetail(entry) }}</text>
</view>
<text class="box-list-tag" :class="boxListType === 'done' ? 'box-list-tag-done' : 'box-list-tag-pending'">
{{ boxListType === 'done' ? '已完成 ✓' : (entry.index === currentBoxIndex ? '拣货中' : '待拣') }}
</text>
</view>
</view>
</uni-popup>
<uni-popup ref="skipPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('skip', $event)">
<view class="modal-sheet skip-modal" role="dialog" aria-modal="true" aria-label="选择跳拣原因" @tap.stop>
<PopupHeader title="选择跳拣原因" @close="closeOverlay" />
<view class="skip-reasons">
<button
v-for="reason in skipReasons"
:key="reason"
class="skip-reason-btn"
:class="{ selected: selectedSkipReason === reason }"
@tap="selectedSkipReason = reason"
>
{{ reason }}
</button>
</view>
<view class="modal-actions">
<button class="modal-cancel-btn" @tap="closeOverlay">取消</button>
<button class="modal-confirm-btn skip-confirm-btn" @tap="confirmSkip">确认跳拣</button>
</view>
</view>
</uni-popup>
<uni-popup ref="partialPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('partial', $event)">
<view class="modal-sheet partial-modal" role="dialog" aria-modal="true" aria-label="部分拣货" @tap.stop>
<PopupHeader title="部分拣货" @close="closeOverlay" />
<view v-if="partialTargetItem" class="partial-info">
<text class="partial-info-sku">{{ partialTargetItem.sku }}</text>
<text class="partial-info-desc">{{ partialTargetItem.desc }}</text>
<view class="partial-info-qty">需拣总量:<text>{{ partialTargetItem.qty }}</text> {{ partialTargetItem.unit }}</view>
</view>
<view class="partial-input-row">
<text class="partial-input-label">实际拣出</text>
<input
v-model="partialInput"
class="partial-input"
aria-label="实际拣出数量"
type="number"
:focus="partialFocused"
confirm-type="done"
@confirm="confirmPartial"
/>
<text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
</view>
<view class="partial-quick-row">
<button
v-for="option in partialQuickOptions"
:key="option.label"
class="partial-quick-btn"
@tap="setPartialInput(option.value)"
>
{{ option.label }} ({{ option.value }})
</button>
</view>
<text v-if="partialValidationMessage" class="partial-validation" role="alert">{{ partialValidationMessage }}</text>
<view class="modal-actions">
<button class="modal-cancel-btn" @tap="closeOverlay">取消</button>
<button class="modal-confirm-btn" :disabled="!partialInputValid" @tap="confirmPartial">确认部分拣货</button>
</view>
</view>
</uni-popup>
<uni-popup ref="switchBoxConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('switchBoxConfirm', $event)">
<view class="modal-sheet decision-modal" role="dialog" aria-modal="true" aria-label="保存当前拣货进度" @tap.stop>
<PopupHeader title="切换前保存当前进度" @close="closeOverlay" />
<text class="decision-desc">当前箱已处理 {{ summary.processed }} 件,还有 {{ summary.pending }} 件待拣。进度会保存为本机演示草稿。</text>
<view class="decision-summary">
<view><text class="decision-number">{{ summary.processed }}</text><text>已处理</text></view>
<view><text class="decision-number warning">{{ summary.pending }}</text><text>待拣</text></view>
</view>
<view class="modal-actions">
<button class="modal-cancel-btn" @tap="closeOverlay">继续当前箱</button>
<button class="modal-confirm-btn" @tap="confirmBoxSwitch">保存并切换</button>
</view>
</view>
</uni-popup>
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
<view class="pending-sku-list">
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.sku }} · {{ item.location }}</text>
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
</view>
<text class="decision-label">选择未完成原因</text>
<view class="incomplete-reasons">
<button v-for="reason in incompleteReasons" :key="reason" class="incomplete-reason" :class="{ selected: selectedIncompleteReason === reason }" @tap="selectedIncompleteReason = reason">{{ reason }}</button>
</view>
<view class="modal-actions submit-decision-actions">
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
<button class="modal-confirm-btn danger-confirm" :disabled="!selectedIncompleteReason" @tap="confirmIncompleteSubmit">确认未拣并提交</button>
</view>
</view>
</uni-popup>
<uni-popup ref="completePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('complete', $event)">
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop>
<PopupHeader title="拣货完成" @close="closeOverlay" />
<view class="complete-icon">✓</view>
<text class="complete-desc">箱号 {{ currentBox.boxNo }} · 订单 {{ currentBox.orderNo }}</text>
<view class="complete-stats">
<view class="complete-stat">
<text class="complete-stat-num green">{{ completionSummary.picked }}</text>
<text class="complete-stat-label">已拣出</text>
</view>
<view class="complete-stat">
<text class="complete-stat-num blue">{{ completionSummary.partial }}</text>
<text class="complete-stat-label">部分拣货</text>
</view>
<view class="complete-stat">
<text class="complete-stat-num gray">{{ completionSummary.skipped }}</text>
<text class="complete-stat-label">跳拣/未拣</text>
</view>
<view v-if="completionSummary.autoSkipped > 0" class="complete-stat">
<text class="complete-stat-num red">{{ completionSummary.autoSkipped }}</text>
<text class="complete-stat-label">自动未拣</text>
</view>
</view>
<view class="complete-btns">
<button class="complete-btn-back" @tap="closeOverlay">返回查看</button>
<button class="complete-btn-next" @tap="nextBox">{{ nextBoxButtonText }}</button>
</view>
</view>
</uni-popup>
<uni-popup ref="allCompletePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('allComplete', $event)">
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="全部箱号已完成" @tap.stop>
<PopupHeader title="全部箱号已完成" @close="closeOverlay" />
<view class="complete-icon">✓</view>
<text class="complete-desc">本班次演示任务 {{ boxes.length }} 箱已全部处理</text>
<button class="all-complete-btn" @tap="leavePicking">返回工作台</button>
</view>
</uni-popup>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
</view>
</template>
<script>
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
import { session, requireSession } from '@/stores/session.js'
import { clearPickingDraft, getPickingSession, restorePickingSession, savePickingSession } from '@/stores/picking.js'
import { navigateBackOr } from '@/utils/navigation.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingInventorySnapshot, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js'
import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js'
import { isOnline } from '@/utils/network.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import PopupHeader from '@/components/PopupHeader.vue'
const VERSION_SYNC_MESSAGE = '状态尚未同步,请刷新重试'
function isPickingCompleteTask(task) {
const workflowStatus = task?.workflowStatus
const taskStatus = task?.taskStatus ?? task?.status
return workflowStatus === 'AWAITING_REVIEW'
|| workflowStatus === 'COMPLETED'
|| taskStatus === 'COMPLETED'
}
export default {
components: { PopupHeader, ScanTargetInput },
data() {
return {
ITEM_STATUS,
currentUser: session.currentUser,
skipReasons: SKIP_REASONS,
incompleteReasons: INCOMPLETE_REASONS,
...getPickingSession(),
activeScanField: 'boxCode',
partialFocused: false,
boxDropdownOpen: false,
activeOverlay: '',
boxListType: 'pending',
skipTargetId: null,
selectedSkipReason: '',
partialTargetId: null,
partialInput: '',
switchTargetIndex: null,
selectedIncompleteReason: '',
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
evidenceSlots: EVIDENCE_SLOTS,
evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null,
remoteTaskId: null, assignmentVersion: null, submitId: '',
taskRequestId: 0,
remoteError: '', remoteLoading: false, submitting: false,
toast: { visible: false, message: '', type: '' }
}
},
computed: {
currentBox() {
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] }
},
summary() {
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
},
orderedItems() {
const pending = []
const processed = []
this.currentBox.items.forEach((item) => {
if (this.itemState(item) === ITEM_STATUS.PENDING) pending.push(item)
else processed.push(item)
})
return pending.concat(processed)
},
pendingItems() {
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
},
lastScanResult() {
return this.scanHistory[0] || null
},
completedCount() {
return Object.keys(this.completedBoxes).filter((key) => this.completedBoxes[key]).length
},
pendingBoxCount() {
return this.boxes.length - this.completedCount
},
pickingCompleted() {
return Boolean(this.completedBoxes[this.currentBoxIndex])
|| isPickingCompleteTask(this.remotePick)
},
partialTargetItem() {
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
},
partialQuickOptions() {
if (!this.partialTargetItem) return []
const quantity = this.partialTargetItem.qty
return [
{ label: '1/4', value: Math.floor(quantity * 0.25) },
{ label: '1/2', value: Math.floor(quantity * 0.5) },
{ label: '3/4', value: Math.floor(quantity * 0.75) },
{ label: '差1', value: Math.max(1, quantity - 1) }
]
},
partialInputValid() {
if (!this.partialTargetItem) return false
const value = parseInt(this.partialInput, 10)
const available = this.availablePickQuantity(this.partialTargetItem)
return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty && (available === null || value <= available)
},
partialValidationMessage() {
if (!this.partialInput || !this.partialTargetItem) return ''
const value = parseInt(this.partialInput, 10)
if (!Number.isInteger(value) || value <= 0) return '请输入大于 0 的整数'
if (value >= this.partialTargetItem.qty) return `部分拣货必须少于 ${this.partialTargetItem.qty},整单请返回后点击“确认拣出”`
const available = this.availablePickQuantity(this.partialTargetItem)
if (available !== null && value > available) return `超过库存/库位可用量,可拣 ${available}`
return ''
},
submitButtonText() {
return this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted ? '该箱已完成' : this.summary.buttonText
},
submitStatusHint() {
if (this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted) return '本箱任务已完成'
if (!this.summary.canSubmit) return '请先处理至少一件'
if (this.summary.pending > 0) return '提交时需选择原因'
return `已处理 ${this.summary.processed}/${this.summary.total}`
},
nextBoxButtonText() {
return this.completedCount === this.boxes.length ? '查看全部完成' : '下一箱'
},
visibleBoxList() {
return this.boxes
.map((box, index) => ({ box, index }))
.filter((entry) => this.boxListType === 'done'
? Boolean(this.completedBoxes[entry.index])
: !this.completedBoxes[entry.index])
},
remoteSyncState() {
if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return { label: '未连接', tone: 'error' }
if (this.remoteLoading) return { label: '同步中', tone: 'loading' }
if (this.remoteError) return { label: '同步失败', tone: 'error' }
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
},
evidenceComplete() {
return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim())
}
},
created() {
this.resetEvidenceState()
},
onLoad(options = {}) {
if (!requireSession()) return
if (options.taskId && (options.cabinetNo || options.containerNo)) {
this.boxes = [{
boxNo: String(options.cabinetNo || options.containerNo), cabinetNo: String(options.cabinetNo || options.containerNo), currentContainerNo: options.currentContainerNo ? String(options.currentContainerNo) : null, orderNo: String(options.orderNo || options.inboundCode || ''),
taskId: String(options.taskId), loadingPlanId: options.loadingPlanId ? String(options.loadingPlanId) : null, containerBookingId: options.containerBookingId ? String(options.containerBookingId) : null,
inboundId: options.inboundId ? String(options.inboundId) : null,
inboundVersion: options.inboundVersion == null ? null : Number(options.inboundVersion),
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
tenantId: options.tenantId ? String(options.tenantId) : null, items: []
}]
this.currentBoxIndex = 0
this.remoteTaskId = options.taskId ? String(options.taskId) : null
}
},
onShow() {
if (!requireSession()) return
this.startHardwareScanner()
this.syncRemoteTask()
},
onHide() { this.stopHardwareScanner() },
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
onBackPress() {
if (this.activeOverlay) { this.closeOverlay(); return true }
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
this.persistSession()
return false
},
methods: {
debugSubmit(event, payload = {}) {
try {
console.info('[PDA] picking.submit', {
event,
taskId: this.remoteTaskId,
loadingPlanId: this.currentBox.loadingPlanId,
containerBookingId: this.currentBox.containerBookingId,
warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse,
pickingVersion: this.pickingVersion,
pending: this.summary.pending,
...payload
})
} catch (_) { /* console may be unavailable on a device */ }
},
isEvidenceContextActive(contextId, inboundId) {
return contextId === this.evidenceContextId
&& String(this.currentBox.inboundId ?? '') === String(inboundId ?? '')
},
requireEvidenceStage() {
if (this.pickingCompleted) return true
this.showToast('请先提交拣货完成', 'warning')
return false
},
resetEvidenceState() {
const contextId = this.evidenceContextId + 1
this.evidenceContextId = contextId
this.evidenceFiles = {}
this.evidenceQueueItems = []
this.sealNo = ''
this.evidenceQueue = createEvidenceUploadQueue({
upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress),
onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items }
})
},
normalizeScanHistory(entries) {
if (!Array.isArray(entries)) return []
return entries.map((entry) => {
const normalized = { ...entry, boxCode: entry.boxCode ?? entry.sku ?? '' }
delete normalized.sku
return normalized
})
},
draftIdentity() {
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId, loadingPlanId: this.currentBox.loadingPlanId || this.remotePick?.loadingPlanId, containerBookingId: this.currentBox.containerBookingId || this.remotePick?.containerBookingId, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse }
},
restoreRemoteDraft() {
const restored = restorePickingSession(this.draftIdentity())
if (!restored) return false
const itemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
this.itemStates = Object.fromEntries(Object.entries(restored.itemStates || {}).filter(([id]) => itemIds.has(String(id))))
this.partialQuantities = Object.fromEntries(Object.entries(restored.partialQuantities || {}).filter(([id]) => itemIds.has(String(id))))
this.scannedBoxCodes = { ...(restored.scannedBoxCodes || {}) }
this.boxDrafts = restored.boxDrafts || {}
this.completedBoxes = restored.completedBoxes || {}
this.scanHistory = this.normalizeScanHistory(restored.scanHistory)
return true
},
handleOverlayChange(name, event) {
if (!event.show && this.activeOverlay === name) this.resetOverlayState()
},
startHardwareScanner() {
this.stopHardwareScanner()
this._stopScanBroadcast = startScanBroadcast({
onScan: (value) => this.handleHardwareScan(value),
onError: () => this.showToast('硬件扫码接收器启动失败,请检查扫描服务', 'warning')
})
},
stopHardwareScanner() {
if (typeof this._stopScanBroadcast !== 'function') return
this._stopScanBroadcast()
this._stopScanBroadcast = null
},
handleHardwareScan(value) {
if (this.activeOverlay || this.boxDropdownOpen) return
if (this.activeScanField !== 'boxCode') return
this.scanValue = value
this.processScannedValue(value, 'broadcast')
},
handleManualSubmit(value) {
if (this.activeOverlay || this.boxDropdownOpen) return
const normalizedValue = String(value || '').trim().toUpperCase()
this.scanValue = normalizedValue
this.processScannedValue(normalizedValue, 'manual')
},
setScanTarget(fieldName) { this.activeScanField = fieldName },
openOverlay(name) {
this.activeOverlay = name
this.$nextTick(() => {
const popup = this.$refs[`${name}Popup`]
if (popup) popup.open()
})
},
persistSession() {
this.saveCurrentBoxDraft()
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, this.draftIdentity())
},
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
clearTimers() { clearTimeout(this._toastTimer) },
scrollToTop() { this.$nextTick(() => { if (typeof uni !== 'undefined' && typeof uni.pageScrollTo === 'function') uni.pageScrollTo({ scrollTop: 0, duration: 0 }) }) },
saveCurrentBoxDraft() {
if (!this.currentBox.boxNo) return
this.boxDrafts = {
...this.boxDrafts,
[this.currentBoxIndex]: {
itemStates: { ...this.itemStates },
partialQuantities: { ...this.partialQuantities },
scannedBoxCodes: { ...this.scannedBoxCodes },
scanHistory: this.scanHistory.map((entry) => ({ ...entry }))
}
}
},
loadBoxState(index) {
const draft = this.boxDrafts[index]
if (draft) {
this.itemStates = { ...draft.itemStates }
this.partialQuantities = { ...draft.partialQuantities }
this.scannedBoxCodes = { ...(draft.scannedBoxCodes || {}) }
this.scanHistory = this.normalizeScanHistory(draft.scanHistory)
} else {
this.itemStates = createItemStates(this.currentBox.items)
this.partialQuantities = {}
this.scannedBoxCodes = {}
this.scanHistory = []
}
this.scanValue = ''
},
toggleBoxDropdown() {
this.boxDropdownOpen = !this.boxDropdownOpen
},
requestBoxSwitch(index) {
if (index === this.currentBoxIndex) {
this.boxDropdownOpen = false
return
}
this.boxDropdownOpen = false
if (!this.completedBoxes[this.currentBoxIndex] && this.summary.processed > 0 && this.summary.pending > 0) {
this.switchTargetIndex = index
this.openOverlay('switchBoxConfirm')
return
}
this.selectBox(index)
},
confirmBoxSwitch() {
if (this.switchTargetIndex === null) return
const targetIndex = this.switchTargetIndex
this.closeOverlay(false)
this.selectBox(targetIndex)
},
selectBox(index) {
this.saveCurrentBoxDraft()
this.taskRequestId += 1
this.submitting = false
this.currentBoxIndex = index
this.remoteTaskId = null
this.remotePick = null
this.pickingVersion = null
this.loadingVersion = null
this.assignmentVersion = null
this.resetEvidenceState()
this.boxDropdownOpen = false
this.loadBoxState(index)
this.scrollToTop()
if (session.token && session.tenantId && this.currentBox.boxNo) this.syncRemoteTask()
this.showToast(`切换至 ${this.currentBox.boxNo}`, 'success')
},
itemState(item) {
return this.itemStates[item.id] || ITEM_STATUS.PENDING
},
hasBoxDraft(index) {
const draft = this.boxDrafts[index]
return Boolean(draft && Object.values(draft.itemStates).some((status) => status !== ITEM_STATUS.PENDING))
},
itemDescription(item) {
const reason = this.itemState(item) === ITEM_STATUS.SKIPPED && item.skipReason
? ` · ${item.skipReason}`
: ''
return `${item.desc}${reason}`
},
processScannedValue(scanValue, source = 'broadcast') {
const value = scanValue.trim()
if (!value) {
const manual = source === 'manual'
playErrorSound()
this.recordScanResult('error', manual ? '未输入箱码' : '未读取到条码', manual ? '输入内容为空' : '广播值为空', manual ? '请输入箱码后按“完成”' : '请重新扫描并检查设备广播配置')
this.showToast(manual ? '请输入箱码' : '广播扫码结果为空', 'warning')
return
}
const result = classifyScan(this.currentBox.items, this.itemStates, value, this.scannedBoxCodes)
if (result.type === 'processed') {
playErrorSound()
const labels = {
[ITEM_STATUS.PICKED]: '已拣出',
[ITEM_STATUS.PARTIAL]: '已部分拣货',
[ITEM_STATUS.SKIPPED]: '已跳拣'
}
this.recordScanResult('warning', '重复扫描', value, `该箱码已${labels[result.status]},无需重复处理`)
this.showToast(`该箱码已处理:${value}`, 'warning')
return
}
if (result.type === 'unknown') {
playErrorSound()
this.recordScanResult('error', '当前任务无此箱码', value, '请核对箱唛标签或转入异常处理')
this.showToast(`未找到匹配的待拣箱码:${value}`, 'error')
return
}
playSuccessSound()
this.pickScannedBox(result.item, value)
},
evidenceEntries(type) { return Array.isArray(this.evidenceFiles[type]) ? this.evidenceFiles[type].filter((item) => item.status !== 'VOID' && item.status !== 'VOIDED') : (this.evidenceFiles[type] ? [this.evidenceFiles[type]] : []) },
validEvidenceEntries(type) { return this.evidenceEntries(type).filter((item) => item.id && item.fileId && item.uploadStatus !== 'PENDING') },
evidenceEntry(type) { const list = this.evidenceEntries(type); return list.length ? list[list.length - 1] : null },
evidenceQueueEntry(type) {
return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null
},
evidenceUploadState(type) {
const item = this.evidenceQueueEntry(type)
if (!item) return ''
if (item.status === 'PENDING') return '本地待上传'
if (item.status === 'UPLOADING') return `上传中 ${item.progress || 0}%`
if (item.status === 'FAILED') return item.error || '上传失败'
if (item.status === 'SUCCEEDED') return item.attempts > 1 ? '重试成功' : '已上传'
if (item.status === 'REPLACED') return '已替换'
return ''
},
async syncRemoteTask() {
if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return
const requestId = ++this.taskRequestId
this.debugSubmit('refresh.start', { requestId, inboundId: this.currentBox.inboundId || null, cabinetNo: this.currentBox.cabinetNo || null })
this.remoteLoading = true
this.remoteError = ''
try {
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const task = await getContainerPickingTask({ taskId: this.remoteTaskId || this.currentBox.taskId, cabinetNo: this.currentBox.cabinetNo, loadingPlanId: this.currentBox.loadingPlanId }, warehouseId)
if (requestId !== this.taskRequestId) return
if (!task) throw new Error('未找到当前柜次的拣货任务')
const remoteItems = task.items || task.pickItems || task.details || task.lines
if (Array.isArray(remoteItems) && remoteItems.length) {
this.currentBox.items = remoteItems.map((item) => ({
...item,
id: item.id ?? item.itemId,
// The batch endpoint expects the PDA task-item primary key (`id`),
// not a nested loading-plan item alias when both are returned.
taskItemId: item.id ?? item.itemId,
boxCodes: Array.isArray(item.boxCodes) ? item.boxCodes.map(String) : [],
orderNo: item.orderNo ?? '', inboundCode: item.inboundCode ?? '',
desc: item.cargoName ?? item.desc ?? item.description ?? '箱级拣货明细',
qty: Number(item.qty ?? item.plannedPackageCount ?? item.packageCount ?? 0),
unit: '箱',
location: item.location ?? item.locationCode ?? '',
availablePackageCount: item.availablePackageCount ?? item.locationAvailablePackageCount ?? item.availableQty ?? item.stockAvailableQty ?? null,
locationIds: item.locationIds ?? item.reservationIds ?? [],
locationAvailable: item.locationAvailable ?? item.locationUsable ?? null,
locationStatus: item.locationStatus ?? null,
skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0
}))
const firstItem = this.currentBox.items[0]
this.currentBox.orderNo = firstItem.orderNo || firstItem.inboundCode || this.currentBox.orderNo
this.currentBox.inboundId = firstItem.inboundId || this.currentBox.inboundId
this.currentBox.tenantId = firstItem.tenantId || this.currentBox.tenantId
const serverStates = createItemStates(this.currentBox.items)
const serverPartialQuantities = {}
this.currentBox.items.forEach((item) => {
if (item.itemStatus === 'PICKED') serverStates[item.id] = ITEM_STATUS.PICKED
else if (item.itemStatus === 'PARTIAL') {
serverStates[item.id] = ITEM_STATUS.PARTIAL
serverPartialQuantities[item.id] = Number(item.pickedPackageCount ?? item.finalPickedPackageCount ?? 0)
} else if (item.itemStatus === 'SKIPPED') serverStates[item.id] = ITEM_STATUS.SKIPPED
})
this.itemStates = serverStates
this.partialQuantities = serverPartialQuantities
this.scannedBoxCodes = {}
}
this.remoteTaskId = task.taskId ?? task.id
if (!this.remoteTaskId) throw new Error('服务端任务缺少 taskId,无法开始拣货')
this.assignmentVersion = task.assignmentVersion ?? task.version ?? 0
this.currentBox.loadingPlanId = task.loadingPlanId
this.currentBox.containerBookingId = task.containerBookingId
this.currentBox.cabinetNo = task.cabinetNo
this.currentBox.boxNo = task.cabinetNo
this.currentBox.currentContainerNo = task.currentContainerNo
const remoteTask = normalizePickingTask(task)
let claimed = null
if (!isPickingCompleteTask(remoteTask)) {
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
if (requestId !== this.taskRequestId) return
}
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
this.loadingVersion = this.currentBox.inboundVersion ?? this.remotePick.inboundVersion ?? null
this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion })
this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion
if (!this.pickingCompleted) this.restoreRemoteDraft()
this.resetEvidenceState()
if (this.pickingCompleted) await this.loadLoadingEvidence()
} catch (error) {
if (requestId !== this.taskRequestId) return
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败')
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
},
async loadLoadingEvidence() {
if (!this.pickingCompleted || !this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) return
const contextId = this.evidenceContextId
try {
const evidence = await getEvidence(this.currentBox.inboundId, this.currentBox.tenantId)
if (contextId !== this.evidenceContextId) return
this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {})
const seal = this.evidenceEntry('SEAL')
this.sealNo = seal?.sealNo || ''
} catch (error) {
if (contextId !== this.evidenceContextId) return
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败')
}
},
async refreshTask() {
if (!this.currentBox.boxNo) return
this.persistSession()
await this.syncRemoteTask()
if (!this.remoteError) this.showToast('任务已刷新,本地拣货标记已保留', 'success')
},
evidenceUploadLabel(type) {
const queued = this.evidenceQueueEntry(type)
if (queued) return queued.status === 'UPLOADING' ? `上传中 ${queued.progress || 0}%` : '等待上传'
return this.evidenceEntry(type) ? '继续拍照' : '拍照'
},
chooseEvidence(slot) {
if (!this.requireEvidenceStage()) return
if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return }
if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return }
const contextId = this.evidenceContextId
const inboundId = this.currentBox.inboundId
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => {
if (!this.isEvidenceContextActive(contextId, inboundId)) return
const path = result.tempFilePaths && result.tempFilePaths[0]
if (!path) return
const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' }
this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] }
this.queueEvidenceUpload(slot.type, path)
}, fail: () => {
if (!this.isEvidenceContextActive(contextId, inboundId)) return
this.showToast('未选择照片,可稍后重试', 'warning')
} })
},
async queueEvidenceUpload(type, path) {
if (!this.requireEvidenceStage()) return
if (!this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
const queue = this.evidenceQueue
const entry = queue.add({ inboundId: this.currentBox.inboundId, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path })
await queue.flush()
if (contextId !== this.evidenceContextId || queue !== this.evidenceQueue) return
const latest = queue.snapshot().find((item) => item.id === entry.id)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
const current = this.evidenceEntry(type) || {}
this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] }
await this.saveEvidence(type)
},
async retryEvidence(type) {
if (!this.requireEvidenceStage()) return
const item = this.evidenceQueueEntry(type)
const contextId = this.evidenceContextId
const queue = this.evidenceQueue
if (!item || !queue.retry(item.id)) return
this.remoteError = ''
await queue.flush()
if (contextId !== this.evidenceContextId || queue !== this.evidenceQueue) return
const latest = queue.latest(type)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
const current = this.evidenceEntry(type) || {}
this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] }
await this.saveEvidence(type)
},
async saveEvidence(type, operationId = null) {
if (!this.requireEvidenceStage()) return
if (!this.currentBox.inboundId) { this.showToast('演示任务已记录本地证据', 'success'); return }
if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
const inboundId = this.currentBox.inboundId
const tenantId = this.currentBox.tenantId
const current = this.evidenceEntry(type) || {}
if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return }
const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${this.remoteTaskId || this.currentBox.inboundId}-${type}-${current.fileId || 'seal'}` }
try {
const result = await upsertEvidence(inboundId, payload, tenantId)
if (!this.isEvidenceContextActive(contextId, inboundId)) return
this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles)
this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, inboundId)) return
this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败')
this.showToast('证据保存失败', 'error')
}
},
async saveSealNo() {
if (!this.requireEvidenceStage()) return
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
await this.saveEvidence('SEAL', `seal-no-${this.remoteTaskId || this.currentBox.inboundId}-${Date.now()}`)
},
async voidEvidencePhoto(photo) {
if (!this.requireEvidenceStage()) return
if (!photo?.id || !this.currentBox.inboundId) return
const contextId = this.evidenceContextId
const inboundId = this.currentBox.inboundId
const tenantId = this.currentBox.tenantId
const reason = 'PDA现场标记错图'
try {
await voidEvidence(inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${inboundId}-${photo.id}-${photo.version || 0}` }, tenantId)
if (!this.isEvidenceContextActive(contextId, inboundId)) return
this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) }
this.showToast('照片已作废,可重新拍摄', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, inboundId)) return
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败')
this.showToast('照片作废失败', 'error')
}
},
async completeLoadingTask() {
if (!this.requireEvidenceStage()) return
if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return }
if (this.loadingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
const contextId = this.evidenceContextId
const inboundId = this.currentBox.inboundId
const tenantId = this.currentBox.tenantId
const loadingVersion = this.loadingVersion
this.submitting = true
try {
await completeLoading(inboundId, { expectedVersion: loadingVersion, remark: 'PDA确认装柜完成' }, tenantId)
if (!this.isEvidenceContextActive(contextId, inboundId)) return
this.showToast('已确认装柜完成', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, inboundId)) return
this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败')
this.showToast('装柜完成失败', 'error')
}
finally {
if (this.isEvidenceContextActive(contextId, inboundId)) this.submitting = false
}
},
pickScannedBox(item, boxCode) {
const pickedCount = Number(this.partialQuantities[item.id] || 0) + 1
const plannedCount = Number(item.qty || 0)
this.scannedBoxCodes = { ...this.scannedBoxCodes, [boxCode]: item.id }
this.partialQuantities = { ...this.partialQuantities, [item.id]: pickedCount }
this.itemStates = { ...this.itemStates,
[item.id]: pickedCount >= plannedCount ? ITEM_STATUS.PICKED : ITEM_STATUS.PARTIAL }
this.recordScanResult('success', '箱码扫描成功', boxCode,
`${item.inboundCode || item.orderNo || '当前明细'} · 已拣 ${pickedCount}/${plannedCount} 箱`)
this.showToast(`已拣箱 ${pickedCount}/${plannedCount}`, 'success')
this.persistSession()
},
pickItem(id, source = 'manual') {
const item = this.currentBox.items.find((entry) => entry.id === id)
const available = this.availablePickQuantity(item)
if (available !== null && available < Number(item.qty || 0)) { this.showToast(`库存/库位可用量不足:可拣 ${available}`, 'error'); return }
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED }
this.partialQuantities = { ...this.partialQuantities, [id]: Number(item?.qty || 0) }
this.showToast(item ? `已确认拣出 ${item.qty} 箱` : '已确认拣出', 'success')
this.persistSession()
},
recordScanResult(type, title, boxCode, detail) {
const entry = { id: `${Date.now()}-${this.scanHistory.length}-${boxCode}`, type, title, boxCode, detail }
this.scanHistory = [entry, ...this.scanHistory].slice(0, 3)
},
undoItem(id) {
const item = this.currentBox.items.find((entry) => entry.id === id)
if (item) item.skipReason = ''
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PENDING }
const nextPartialQuantities = { ...this.partialQuantities }
delete nextPartialQuantities[id]
this.partialQuantities = nextPartialQuantities
this.scannedBoxCodes = Object.fromEntries(Object.entries(this.scannedBoxCodes)
.filter(([, itemId]) => String(itemId) !== String(id)))
this.showToast('已撤销操作', 'success')
this.persistSession()
},
openSkip(id) {
this.skipTargetId = id
this.selectedSkipReason = ''
this.openOverlay('skip')
},
confirmSkip() {
if (!this.selectedSkipReason) {
this.showToast('请先选择跳拣原因', 'warning')
return
}
const item = this.currentBox.items.find((entry) => entry.id === this.skipTargetId)
if (item) item.skipReason = this.selectedSkipReason
this.itemStates = { ...this.itemStates, [this.skipTargetId]: ITEM_STATUS.SKIPPED }
const reason = this.selectedSkipReason
this.closeOverlay(false)
this.showToast(`已标记跳拣:${reason}`, 'warning')
this.persistSession()
},
openPartial(id) {
this.partialTargetId = id
this.partialInput = ''
this.openOverlay('partial')
this.$nextTick(() => {
this.partialFocused = true
})
},
setPartialInput(value) {
this.partialInput = String(value)
this.partialFocused = false
this.$nextTick(() => {
this.partialFocused = true
})
},
confirmPartial() {
const item = this.partialTargetItem
if (!item) return
if (!this.partialInputValid) return
const value = parseInt(this.partialInput, 10)
const available = this.availablePickQuantity(item)
if (available !== null && value > available) { this.showToast(`超过库存/库位可用量:可拣 ${available}`, 'error'); return }
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PARTIAL }
this.partialQuantities = { ...this.partialQuantities, [item.id]: value }
this.closeOverlay(false)
this.showToast(`已部分拣货:${value}/${item.qty} ${item.unit}`, 'success')
this.persistSession()
},
availablePickQuantity(item) {
if (!item) return null
if (item.locationAvailable === false || item.locationStatus === 'DISABLED') return 0
const raw = item.locationAvailablePackageCount ?? item.availablePackageCount ?? item.availableQty ?? item.stockAvailableQty
return raw === null || raw === undefined || raw === '' ? null : Math.max(0, Number(raw))
},
openBoxList(type) {
this.boxListType = type
this.boxDropdownOpen = false
this.openOverlay('boxList')
},
boxListDetail(entry) {
if (this.boxListType !== 'done') {
const draft = this.boxDrafts[entry.index]
const processed = draft
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
: 0
return `${entry.box.orderNo} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
}
const picked = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PICKED).length
const partial = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PARTIAL).length
return `${entry.box.orderNo} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
},
selectBoxFromList(entry) {
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
this.closeOverlay(false)
this.requestBoxSwitch(entry.index)
},
submitPicking() {
if (!this.summary.canSubmit || this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted) return
if (this.summary.pending > 0) {
this.selectedIncompleteReason = ''
this.openOverlay('submitConfirm')
return
}
this.performSubmit()
},
confirmIncompleteSubmit() {
if (!this.selectedIncompleteReason) return
this.performSubmit(this.selectedIncompleteReason)
},
async performSubmit(incompleteReason = '') {
const beforeSubmit = this.summary
const remoteRequired = Boolean(session.token)
this.remoteError = ''
this.debugSubmit('submit.start', { incompleteReason, remoteRequired, remoteReady: isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo) })
if (remoteRequired && !isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) {
this.remoteError = '缺少服务端任务信息,未提交拣货完成'
this.showToast('拣货完成未提交', 'error')
return
}
if (remoteRequired) {
this.submitting = true
try {
if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试')
if (this.pickingVersion === null) throw new Error(VERSION_SYNC_MESSAGE)
const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason })
this.submitId = submitId
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const inventorySnapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null })
if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
const actions = this.currentBox.items.map((item) => {
const status = this.itemState(item)
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) }
if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) }
if (status === ITEM_STATUS.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣', version: Number(item.version || 0) }
if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) }
return null
}).filter(Boolean)
this.debugSubmit('submit.batch', { localItemIds: this.currentBox.items.map((item) => item.id), actionItemIds: actions.map((action) => action.itemId), actionCount: actions.length })
const submittedTask = await submitContainerPickingBatch({ taskId: this.remoteTaskId, loadingPlanId: this.currentBox.loadingPlanId, containerBookingId: this.currentBox.containerBookingId, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken })
this.debugSubmit('submit.success', { itemCount: actions.length, workflowStatus: submittedTask?.workflowStatus || null, taskStatus: submittedTask?.taskStatus || null })
if (submittedTask) this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask })
// Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions.
} catch (error) {
this.debugSubmit('submit.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '', network: Boolean(error.network) })
const versionConflict = error.code === 'A0409' || error.statusCode === 409
const inventoryConflict = versionConflict && /库存/.test(error.message || '')
const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试'
this.remoteError = versionConflict ? conflictMessage : (error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败'))
this.showToast(versionConflict ? conflictMessage : '拣货完成未提交,请查看错误提示', 'error')
return
} finally { this.submitting = false }
}
const result = submitStates(this.currentBox.items, this.itemStates)
this.itemStates = result.states
this.currentBox.items.forEach((item) => {
if (result.autoSkippedIds.includes(item.id)) {
item.skipReason = `${incompleteReason}(提交时确认未拣)`
}
item.finalState = this.itemState(item)
item.finalPartialQuantity = this.partialQuantities[item.id] || 0
})
this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
this.loadLoadingEvidence()
this.saveCurrentBoxDraft()
if (remoteRequired) clearPickingDraft(this.draftIdentity())
this.submitId = ''
const afterSubmit = getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
this.completionSummary = {
picked: afterSubmit.picked,
partial: afterSubmit.partial,
skipped: afterSubmit.skipped,
autoSkipped: beforeSubmit.pending
}
this.openOverlay('complete')
},
nextBox() {
this.closeOverlay(false)
const nextIndex = this.boxes.findIndex((box, index) => !this.completedBoxes[index])
if (nextIndex < 0) {
this.openOverlay('allComplete')
return
}
this.selectBox(nextIndex)
this.showToast('切换至下一箱', 'success')
},
resetOverlayState() {
this.activeOverlay = ''
this.skipTargetId = null
this.selectedSkipReason = ''
this.partialTargetId = null
this.partialInput = ''
this.switchTargetIndex = null
this.selectedIncompleteReason = ''
this.partialFocused = false
},
closeOverlay() {
const popup = this.$refs[`${this.activeOverlay}Popup`]
if (popup) popup.close()
this.resetOverlayState()
},
showToast(message, type = '') {
clearTimeout(this._toastTimer)
this.toast = { visible: true, message, type }
this._toastTimer = setTimeout(() => {
this.toast.visible = false
}, 2200)
}
}
}
</script>