Files
pda-uniapp/pages/picking/picking.vue
T

1218 lines
63 KiB
Vue
Raw Normal View History

2026-07-30 18:04:37 +08:00
<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">
2026-08-12 18:32:31 +08:00
<text>{{ currentBox.boxNo }}<template v-if="currentBox.currentContainerNo"> · 当前柜号 {{ currentBox.currentContainerNo }}</template> · 订单 {{ currentBox.orderNo }}</text>
2026-07-30 18:04:37 +08:00
<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">
2026-08-11 20:39:38 +08:00
<ScanTargetInput v-model="scanValue" name="boxCode" label="箱码扫码框" placeholder="扫描或输入箱唛标签码" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
2026-07-30 18:04:37 +08:00
</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>
2026-08-12 09:26:00 +08:00
<text class="last-scan-box-code">{{ lastScanResult.boxCode }}</text>
2026-07-30 18:04:37 +08:00
<text class="last-scan-detail">{{ lastScanResult.detail }}</text>
</view>
</view>
<view v-if="scanHistory.length > 1" class="scan-history">
2026-08-12 09:26:00 +08:00
<text v-for="entry in scanHistory.slice(1, 3)" :key="entry.id" class="scan-history-item">{{ entry.boxCode }} · {{ entry.title }}</text>
2026-07-30 18:04:37 +08:00
</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>
2026-08-11 20:39:38 +08:00
<text> / {{ summary.total }} 箱</text>
2026-07-30 18:04:37 +08:00
</template>
<template v-else>
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
2026-08-11 20:39:38 +08:00
<text> / {{ summary.total }} 箱</text>
2026-07-30 18:04:37 +08:00
</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">
2026-08-11 20:39:38 +08:00
<text class="pick-item-sku">{{ item.inboundCode || item.orderNo || '箱级拣货明细' }}</text>
2026-07-30 18:04:37 +08:00
<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">
2026-08-11 20:39:38 +08:00
<text>需拣 </text><text class="qty-number">{{ item.qty }}</text><text> 箱</text>
2026-07-30 18:04:37 +08:00
<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>
2026-08-15 09:49:05 +08:00
<view v-if="loadingStageReady" class="loading-evidence-section">
2026-08-06 13:56:01 +08:00
<view class="section-heading-row">
<view><text class="section-title">装柜证据</text><text class="section-subtitle">空仓、半仓、满仓、锁仓及带锁码照</text></view>
2026-08-06 13:56:01 +08:00
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
</view>
<view class="evidence-grid">
2026-08-10 11:53:43 +08:00
<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>
2026-08-06 13:56:01 +08:00
</view>
</view>
<view class="seal-row">
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
2026-08-10 11:53:43 +08:00
<button class="evidence-btn seal-btn" @tap="saveSealNo">保存封签</button>
2026-08-06 13:56:01 +08:00
</view>
2026-08-11 11:31:05 +08:00
<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>
2026-08-06 13:56:01 +08:00
</view>
2026-08-15 09:49:05 +08:00
<view v-if="pickingCompleted && !loadingStageReady" class="remote-error-row submit-error-row">
<text class="version-hint" role="status">拣货已完成,装柜状态尚未同步,请刷新重试</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
</view>
2026-08-06 13:56:01 +08:00
2026-07-30 18:04:37 +08:00
<view class="submit-section">
<view class="submit-info">
2026-08-11 20:39:38 +08:00
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 箱待拣</text></view>
2026-07-30 18:04:37 +08:00
<text class="submit-status-hint">{{ submitStatusHint }}</text>
</view>
2026-08-15 09:47:28 +08:00
<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">
2026-08-06 13:56:01 +08:00
{{ submitting ? '正在提交…' : submitButtonText }}
2026-07-30 18:04:37 +08:00
</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>
2026-08-15 09:47:28 +08:00
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
2026-07-30 18:04:37 +08:00
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'
2026-07-30 18:04:37 +08:00
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'
2026-08-11 11:31:05 +08:00
import { isOnline } from '@/utils/network.js'
import { stableOperationId } from '@/utils/idempotency.js'
2026-08-12 18:32:31 +08:00
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
2026-07-30 18:04:37 +08:00
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'
}
2026-07-30 18:04:37 +08:00
export default {
components: { PopupHeader, ScanTargetInput },
data() {
return {
ITEM_STATUS,
currentUser: session.currentUser,
skipReasons: SKIP_REASONS,
incompleteReasons: INCOMPLETE_REASONS,
...getPickingSession(),
2026-08-11 20:39:38 +08:00
activeScanField: 'boxCode',
2026-07-30 18:04:37 +08:00
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,
2026-08-15 09:49:05 +08:00
evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null,
remoteTaskId: null, assignmentVersion: null, submitId: '',
taskRequestId: 0,
2026-08-06 13:56:01 +08:00
remoteError: '', remoteLoading: false, submitting: false,
2026-07-30 18:04:37 +08:00
toast: { visible: false, message: '', type: '' }
}
},
computed: {
currentBox() {
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] }
},
summary() {
2026-08-11 20:39:38 +08:00
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
2026-07-30 18:04:37 +08:00
},
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)
},
2026-08-15 09:49:05 +08:00
loadingStageReady() {
return this.pickingCompleted
&& this.evidenceIdentity
&& this.evidenceIdentity.inboundId
2026-08-15 09:57:53 +08:00
&& this.loadingVersion !== null
&& this.loadingVersion !== undefined
2026-08-15 09:49:05 +08:00
&& Number.isFinite(Number(this.loadingVersion))
},
2026-07-30 18:04:37 +08:00
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)
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(this.partialTargetItem)
return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty && (available === null || value <= available)
2026-07-30 18:04:37 +08:00
},
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},整单请返回后点击“确认拣出”`
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(this.partialTargetItem)
if (available !== null && value > available) return `超过库存/库位可用量,可拣 ${available}`
2026-07-30 18:04:37 +08:00
return ''
},
2026-08-15 09:47:28 +08:00
submitButtonText() {
return this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted ? '该箱已完成' : this.summary.buttonText
2026-07-30 18:04:37 +08:00
},
submitStatusHint() {
2026-08-15 09:47:28 +08:00
if (this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted) return '本箱任务已完成'
2026-07-30 18:04:37 +08:00
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])
2026-08-06 13:56:01 +08:00
},
remoteSyncState() {
2026-08-11 11:31:05 +08:00
if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return { label: '未连接', tone: 'error' }
2026-08-06 13:56:01 +08:00
if (this.remoteLoading) return { label: '同步中', tone: 'loading' }
if (this.remoteError) return { label: '同步失败', tone: 'error' }
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
2026-08-10 11:53:43 +08:00
},
evidenceComplete() {
return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim())
2026-07-30 18:04:37 +08:00
}
},
created() {
this.resetEvidenceState()
},
2026-08-11 11:31:05 +08:00
onLoad(options = {}) {
if (!requireSession()) return
2026-08-12 18:32:31 +08:00
if (options.taskId && (options.cabinetNo || options.containerNo)) {
2026-08-11 11:31:05 +08:00
this.boxes = [{
2026-08-12 18:32:31 +08:00
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,
2026-08-11 11:31:05 +08:00
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
}
},
2026-07-30 18:04:37 +08:00
onShow() {
if (!requireSession()) return
this.startHardwareScanner()
2026-08-06 13:56:01 +08:00
this.syncRemoteTask()
2026-07-30 18:04:37 +08:00
},
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: {
2026-08-15 09:47:28 +08:00
debugSubmit(event, payload = {}) {
try {
console.info('[PDA] picking.submit', {
event,
taskId: this.remoteTaskId,
2026-08-15 09:57:53 +08:00
inboundId: this.currentBox.inboundId || this.evidenceIdentity?.inboundId || null,
2026-08-15 09:47:28 +08:00
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 */ }
},
2026-08-15 09:49:05 +08:00
captureEvidenceIdentity() {
return {
taskId: this.remoteTaskId || this.currentBox.taskId || null,
inboundId: this.currentBox.inboundId || null,
loadingPlanId: this.currentBox.loadingPlanId || null,
containerBookingId: this.currentBox.containerBookingId || null
}
},
isSameEvidenceIdentity(left, right) {
return ['taskId', 'inboundId', 'loadingPlanId', 'containerBookingId'].every((key) => String(left?.[key] ?? '') === String(right?.[key] ?? ''))
},
isEvidenceContextActive(contextId, identity) {
return contextId === this.evidenceContextId
2026-08-15 09:49:05 +08:00
&& this.isSameEvidenceIdentity(this.evidenceIdentity, identity)
},
requireEvidenceStage() {
2026-08-15 09:49:05 +08:00
if (!this.pickingCompleted) {
this.showToast('请先提交拣货完成', 'warning')
return false
}
if (!this.loadingStageReady) {
this.showToast(VERSION_SYNC_MESSAGE, 'warning')
return false
}
return true
},
resetEvidenceState() {
const contextId = this.evidenceContextId + 1
this.evidenceContextId = contextId
2026-08-15 09:49:05 +08:00
this.evidenceIdentity = null
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 }
})
},
2026-08-12 09:26:00 +08:00
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
2026-08-12 18:32:31 +08:00
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
2026-08-11 11:31:05 +08:00
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))))
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = { ...(restored.scannedBoxCodes || {}) }
this.boxDrafts = restored.boxDrafts || {}
this.completedBoxes = restored.completedBoxes || {}
2026-08-12 09:26:00 +08:00
this.scanHistory = this.normalizeScanHistory(restored.scanHistory)
return true
},
2026-07-30 18:04:37 +08:00
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
2026-08-11 20:39:38 +08:00
if (this.activeScanField !== 'boxCode') return
2026-07-30 18:04:37 +08:00
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()
2026-08-11 20:39:38 +08:00
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())
2026-07-30 18:04:37 +08:00
},
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 },
2026-08-11 20:39:38 +08:00
scannedBoxCodes: { ...this.scannedBoxCodes },
2026-07-30 18:04:37 +08:00
scanHistory: this.scanHistory.map((entry) => ({ ...entry }))
}
}
},
loadBoxState(index) {
const draft = this.boxDrafts[index]
if (draft) {
this.itemStates = { ...draft.itemStates }
this.partialQuantities = { ...draft.partialQuantities }
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = { ...(draft.scannedBoxCodes || {}) }
2026-08-12 09:26:00 +08:00
this.scanHistory = this.normalizeScanHistory(draft.scanHistory)
2026-07-30 18:04:37 +08:00
} else {
this.itemStates = createItemStates(this.currentBox.items)
this.partialQuantities = {}
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = {}
2026-07-30 18:04:37 +08:00
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
2026-07-30 18:04:37 +08:00
this.currentBoxIndex = index
2026-08-15 09:57:53 +08:00
this.currentBox.inboundId = null
this.currentBox.inboundVersion = null
this.remoteTaskId = null
this.remotePick = null
this.pickingVersion = null
this.loadingVersion = null
this.assignmentVersion = null
this.resetEvidenceState()
2026-07-30 18:04:37 +08:00
this.boxDropdownOpen = false
this.loadBoxState(index)
this.scrollToTop()
if (session.token && session.tenantId && this.currentBox.boxNo) this.syncRemoteTask()
2026-07-30 18:04:37 +08:00
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()
2026-08-11 20:39:38 +08:00
this.recordScanResult('error', manual ? '未输入箱码' : '未读取到条码', manual ? '输入内容为空' : '广播值为空', manual ? '请输入箱码后按“完成”' : '请重新扫描并检查设备广播配置')
this.showToast(manual ? '请输入箱码' : '广播扫码结果为空', 'warning')
2026-07-30 18:04:37 +08:00
return
}
2026-08-11 20:39:38 +08:00
const result = classifyScan(this.currentBox.items, this.itemStates, value, this.scannedBoxCodes)
2026-07-30 18:04:37 +08:00
if (result.type === 'processed') {
playErrorSound()
const labels = {
[ITEM_STATUS.PICKED]: '已拣出',
[ITEM_STATUS.PARTIAL]: '已部分拣货',
[ITEM_STATUS.SKIPPED]: '已跳拣'
}
2026-08-11 20:39:38 +08:00
this.recordScanResult('warning', '重复扫描', value, `该箱码已${labels[result.status]},无需重复处理`)
this.showToast(`该箱码已处理:${value}`, 'warning')
2026-07-30 18:04:37 +08:00
return
}
if (result.type === 'unknown') {
playErrorSound()
2026-08-11 20:39:38 +08:00
this.recordScanResult('error', '当前任务无此箱码', value, '请核对箱唛标签或转入异常处理')
this.showToast(`未找到匹配的待拣箱码:${value}`, 'error')
2026-07-30 18:04:37 +08:00
return
}
playSuccessSound()
2026-08-11 20:39:38 +08:00
this.pickScannedBox(result.item, value)
2026-07-30 18:04:37 +08:00
},
2026-08-10 11:53:43 +08:00
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 ''
},
2026-08-06 13:56:01 +08:00
async syncRemoteTask() {
if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return
const requestId = ++this.taskRequestId
2026-08-15 09:49:05 +08:00
const selector = { taskId: this.remoteTaskId || this.currentBox.taskId, cabinetNo: this.currentBox.cabinetNo, loadingPlanId: this.currentBox.loadingPlanId }
this.currentBox.inboundId = null
this.currentBox.loadingPlanId = null
this.currentBox.containerBookingId = null
this.loadingVersion = null
this.resetEvidenceState()
this.debugSubmit('refresh.start', { requestId, cabinetNo: this.currentBox.cabinetNo || null, selector })
2026-08-06 13:56:01 +08:00
this.remoteLoading = true
this.remoteError = ''
try {
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
2026-08-15 09:49:05 +08:00
const task = await getContainerPickingTask(selector, 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,
2026-08-15 09:47:28 +08:00
// 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,
2026-08-11 20:39:38 +08:00
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 ?? '',
2026-08-10 11:53:43 +08:00
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
}))
2026-08-11 20:39:38 +08:00
const firstItem = this.currentBox.items[0]
this.currentBox.orderNo = firstItem.orderNo || firstItem.inboundCode || this.currentBox.orderNo
2026-08-15 09:49:05 +08:00
if (!firstItem.inboundId) throw new Error('服务端任务明细缺少 inboundId,无法进入装柜取证')
this.currentBox.inboundId = firstItem.inboundId
this.currentBox.tenantId = firstItem.tenantId || null
2026-08-15 09:47:28 +08:00
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
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = {}
}
this.remoteTaskId = task.taskId ?? task.id
if (!this.remoteTaskId) throw new Error('服务端任务缺少 taskId,无法开始拣货')
this.assignmentVersion = task.assignmentVersion ?? task.version ?? 0
2026-08-12 18:32:31 +08:00
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
2026-08-15 09:57:53 +08:00
this.loadingVersion = task.inboundVersion ?? task.loadingVersion ?? this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? null
this.evidenceIdentity = this.captureEvidenceIdentity()
this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion, evidenceIdentity: this.evidenceIdentity })
this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion
2026-08-15 09:47:28 +08:00
if (!this.pickingCompleted) this.restoreRemoteDraft()
2026-08-15 09:57:53 +08:00
if (this.loadingStageReady) await this.loadLoadingEvidence()
2026-08-06 13:56:01 +08:00
} catch (error) {
if (requestId !== this.taskRequestId) return
2026-08-15 09:47:28 +08:00
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() {
2026-08-15 09:57:53 +08:00
if (!this.loadingStageReady || !this.evidenceIdentity || !isRemoteReady(this.evidenceIdentity.inboundId)) return
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
try {
2026-08-15 09:57:53 +08:00
const evidence = await getEvidence(identity.inboundId, this.currentBox.tenantId)
if (!this.isEvidenceContextActive(contextId, identity)) 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) {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败')
}
2026-08-06 13:56:01 +08:00
},
2026-08-11 11:31:05 +08:00
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}%` : '等待上传'
2026-08-11 11:31:05 +08:00
return this.evidenceEntry(type) ? '继续拍照' : '拍照'
},
2026-08-06 13:56:01 +08:00
chooseEvidence(slot) {
if (!this.requireEvidenceStage()) return
2026-08-11 11:31:05 +08:00
if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return }
2026-08-06 13:56:01 +08:00
if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return }
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
2026-08-10 11:53:43 +08:00
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-06 13:56:01 +08:00
const path = result.tempFilePaths && result.tempFilePaths[0]
if (!path) return
2026-08-10 11:53:43 +08:00
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: () => {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
this.showToast('未选择照片,可稍后重试', 'warning')
} })
2026-08-06 13:56:01 +08:00
},
async queueEvidenceUpload(type, path) {
if (!this.requireEvidenceStage()) return
2026-08-15 09:57:53 +08:00
if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
const queue = this.evidenceQueue
2026-08-15 09:57:53 +08:00
const entry = queue.add({ ...identity, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path })
await queue.flush()
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.snapshot().find((item) => item.id === entry.id)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
2026-08-10 11:53:43 +08:00
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
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
const queue = this.evidenceQueue
if (!item || !queue.retry(item.id)) return
this.remoteError = ''
await queue.flush()
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.latest(type)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
2026-08-10 11:53:43 +08:00
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)
},
2026-08-10 11:53:43 +08:00
async saveEvidence(type, operationId = null) {
if (!this.requireEvidenceStage()) return
2026-08-15 09:57:53 +08:00
if (!this.evidenceIdentity?.inboundId) { this.showToast('状态尚未同步,请刷新重试', 'warning'); return }
if (!isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
const inboundId = identity.inboundId
const tenantId = this.currentBox.tenantId
2026-08-10 11:53:43 +08:00
const current = this.evidenceEntry(type) || {}
if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return }
2026-08-15 09:57:53 +08:00
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-${identity.taskId || identity.inboundId}-${type}-${current.fileId || 'seal'}` }
2026-08-06 13:56:01 +08:00
try {
const result = await upsertEvidence(inboundId, payload, tenantId)
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-10 11:53:43 +08:00
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)
2026-08-06 13:56:01 +08:00
this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success')
} catch (error) {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败')
this.showToast('证据保存失败', 'error')
}
2026-08-06 13:56:01 +08:00
},
2026-08-10 11:53:43 +08:00
async saveSealNo() {
if (!this.requireEvidenceStage()) return
2026-08-10 11:53:43 +08:00
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
2026-08-15 09:57:53 +08:00
await this.saveEvidence('SEAL', `seal-no-${this.evidenceIdentity?.taskId || this.evidenceIdentity?.inboundId}-${Date.now()}`)
2026-08-10 11:53:43 +08:00
},
async voidEvidencePhoto(photo) {
if (!this.requireEvidenceStage()) return
2026-08-15 09:57:53 +08:00
if (!photo?.id || !this.evidenceIdentity?.inboundId) return
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
const inboundId = identity.inboundId
const tenantId = this.currentBox.tenantId
2026-08-10 11:53:43 +08:00
const reason = 'PDA现场标记错图'
try {
await voidEvidence(inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${inboundId}-${photo.id}-${photo.version || 0}` }, tenantId)
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-10 11:53:43 +08:00
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) {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-10 11:53:43 +08:00
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败')
this.showToast('照片作废失败', 'error')
}
},
2026-08-06 13:56:01 +08:00
async completeLoadingTask() {
if (!this.requireEvidenceStage()) return
2026-08-15 09:57:53 +08:00
if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return }
if (this.loadingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
2026-08-10 11:53:43 +08:00
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
const inboundId = identity.inboundId
const tenantId = this.currentBox.tenantId
const loadingVersion = this.loadingVersion
2026-08-06 13:56:01 +08:00
this.submitting = true
try {
await completeLoading(inboundId, { expectedVersion: loadingVersion, remark: 'PDA确认装柜完成' }, tenantId)
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
this.showToast('已确认装柜完成', 'success')
} catch (error) {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败')
this.showToast('装柜完成失败', 'error')
}
finally {
2026-08-15 09:57:53 +08:00
if (this.isEvidenceContextActive(contextId, identity)) this.submitting = false
}
2026-08-06 13:56:01 +08:00
},
2026-08-11 20:39:38 +08:00
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()
},
2026-07-30 18:04:37 +08:00
pickItem(id, source = 'manual') {
const item = this.currentBox.items.find((entry) => entry.id === id)
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(item)
if (available !== null && available < Number(item.qty || 0)) { this.showToast(`库存/库位可用量不足:可拣 ${available}`, 'error'); return }
2026-07-30 18:04:37 +08:00
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED }
2026-08-11 20:39:38 +08:00
this.partialQuantities = { ...this.partialQuantities, [id]: Number(item?.qty || 0) }
this.showToast(item ? `已确认拣出 ${item.qty} 箱` : '已确认拣出', 'success')
this.persistSession()
2026-07-30 18:04:37 +08:00
},
2026-08-12 09:26:00 +08:00
recordScanResult(type, title, boxCode, detail) {
const entry = { id: `${Date.now()}-${this.scanHistory.length}-${boxCode}`, type, title, boxCode, detail }
2026-07-30 18:04:37 +08:00
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
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = Object.fromEntries(Object.entries(this.scannedBoxCodes)
.filter(([, itemId]) => String(itemId) !== String(id)))
2026-07-30 18:04:37 +08:00
this.showToast('已撤销操作', 'success')
this.persistSession()
2026-07-30 18:04:37 +08:00
},
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()
2026-07-30 18:04:37 +08:00
},
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)
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(item)
if (available !== null && value > available) { this.showToast(`超过库存/库位可用量:可拣 ${available}`, 'error'); return }
2026-07-30 18:04:37 +08:00
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()
2026-07-30 18:04:37 +08:00
},
2026-08-10 11:53:43 +08:00
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))
},
2026-07-30 18:04:37 +08:00
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() {
2026-08-15 09:47:28 +08:00
if (!this.summary.canSubmit || this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted) return
2026-07-30 18:04:37 +08:00
if (this.summary.pending > 0) {
this.selectedIncompleteReason = ''
this.openOverlay('submitConfirm')
return
}
this.performSubmit()
},
confirmIncompleteSubmit() {
if (!this.selectedIncompleteReason) return
this.performSubmit(this.selectedIncompleteReason)
},
2026-08-06 13:56:01 +08:00
async performSubmit(incompleteReason = '') {
2026-07-30 18:04:37 +08:00
const beforeSubmit = this.summary
2026-08-11 11:31:05 +08:00
const remoteRequired = Boolean(session.token)
2026-08-15 09:47:28 +08:00
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)) {
2026-08-11 11:31:05 +08:00
this.remoteError = '缺少服务端任务信息,未提交拣货完成'
2026-08-06 13:56:01 +08:00
this.showToast('拣货完成未提交', 'error')
return
}
if (remoteRequired) {
2026-08-06 13:56:01 +08:00
this.submitting = true
try {
if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试')
if (this.pickingVersion === null) throw new Error(VERSION_SYNC_MESSAGE)
2026-08-12 18:32:31 +08:00
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)
2026-08-15 09:47:28 +08:00
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) => {
2026-08-06 13:56:01 +08:00
const status = this.itemState(item)
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
2026-08-11 11:31:05 +08:00
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)
2026-08-15 09:47:28 +08:00
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 })
2026-08-15 09:47:28 +08:00
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.
2026-08-06 13:56:01 +08:00
} catch (error) {
2026-08-15 09:47:28 +08:00
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 || '拣货完成失败'))
2026-08-15 09:47:28 +08:00
this.showToast(versionConflict ? conflictMessage : '拣货完成未提交,请查看错误提示', 'error')
2026-08-06 13:56:01 +08:00
return
} finally { this.submitting = false }
}
2026-07-30 18:04:37 +08:00
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 }
2026-08-15 09:57:53 +08:00
if (this.loadingStageReady) this.loadLoadingEvidence()
2026-07-30 18:04:37 +08:00
this.saveCurrentBoxDraft()
if (remoteRequired) clearPickingDraft(this.draftIdentity())
this.submitId = ''
2026-08-11 20:39:38 +08:00
const afterSubmit = getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
2026-07-30 18:04:37 +08:00
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>